From bd79a7a4cc5e24def0ec262cfbaa10199ad9e562 Mon Sep 17 00:00:00 2001 From: Ashwin Date: Fri, 7 Aug 2026 15:58:35 +0530 Subject: [PATCH] Refactor code style and improve error handling in analytics API - Cleaned up admin.py by removing unnecessary dictionary wrapping for fields. - Adjusted migration files to maintain consistent formatting and removed redundant lines. - Updated models.py and tests to remove unnecessary blank lines and improve readability. - Enhanced error handling in the analytics API to properly forward upstream status and messages. - Added comprehensive tests for the analytics proxy to ensure correct status and body forwarding. - Updated contributing guidelines to clarify the use of ruff for code style enforcement. --- .github/workflows/tests.yml | 31 +- backend/README.md | 17 +- .../migrations/0007_account_custom_fields.py | 13 +- .../migrations/0008_account_is_sample.py | 12 +- backend/accounts/models.py | 1 - backend/accounts/serializer.py | 1 - .../tests/test_account_access_and_rollups.py | 4 +- backend/accounts/tests/test_accounts_api.py | 6 +- backend/accounts/tests/test_custom_fields.py | 8 +- backend/accounts/views.py | 25 +- backend/business_hours/calendar.py | 4 +- .../business_hours/migrations/0001_initial.py | 224 +++++++++--- backend/business_hours/serializers.py | 5 +- backend/business_hours/tests/test_calendar.py | 1 - backend/cases/approvals.py | 13 +- backend/cases/bulk_views.py | 2 +- backend/cases/import_views.py | 1 - backend/cases/inbound/contacts.py | 1 + backend/cases/inbound/parser.py | 8 +- backend/cases/inbound/sns.py | 14 +- backend/cases/inbound/spam.py | 1 - backend/cases/inbound/threading.py | 1 + backend/cases/kanban_views.py | 2 +- backend/cases/kb_views.py | 8 +- backend/cases/merge_views.py | 21 +- backend/cases/migrations/0009_case_kanban.py | 3 +- .../cases/migrations/0010_reopen_policy.py | 102 +++++- .../migrations/0011_case_custom_fields.py | 13 +- backend/cases/migrations/0012_escalation.py | 181 ++++++++-- .../cases/migrations/0013_inbound_email.py | 332 +++++++++++++++--- backend/cases/migrations/0014_routing.py | 219 ++++++++++-- backend/cases/migrations/0015_merge.py | 37 +- backend/cases/migrations/0016_watchers.py | 134 +++++-- backend/cases/migrations/0017_sla_pause.py | 11 +- .../migrations/0018_case_resolved_idx.py | 1 - backend/cases/migrations/0019_csatsurvey.py | 122 +++++-- .../migrations/0020_case_parent_is_problem.py | 1 - backend/cases/migrations/0021_timeentry.py | 203 +++++++++-- .../0022_approvalrule_approval_and_more.py | 292 ++++++++++++--- .../migrations/0023_case_merge_record.py | 7 +- .../migrations/0024_emailmessage_mailbox.py | 1 - .../0025_inboundmailbox_topic_arn_and_more.py | 1 - .../cases/migrations/0026_case_is_sample.py | 12 +- .../0027_alter_casepipeline_is_default.py | 1 - ...inboundmailbox_webhook_secret_help_text.py | 14 +- backend/cases/notifications.py | 4 +- backend/cases/parent_views.py | 28 +- backend/cases/routing.py | 22 +- backend/cases/serializer.py | 12 +- backend/cases/services/billing.py | 3 +- backend/cases/services/csv_import.py | 26 +- backend/cases/signals.py | 2 +- backend/cases/solution_views.py | 2 +- backend/cases/tasks.py | 2 +- backend/cases/tests/conftest.py | 4 +- backend/cases/tests/test_analytics.py | 56 ++- backend/cases/tests/test_analytics_api.py | 34 +- backend/cases/tests/test_approval_authz.py | 8 +- backend/cases/tests/test_approvals.py | 9 +- backend/cases/tests/test_audit_log.py | 26 +- backend/cases/tests/test_case_merge.py | 50 ++- backend/cases/tests/test_case_solutions.py | 4 +- backend/cases/tests/test_cases_bulk.py | 1 + backend/cases/tests/test_cases_kanban.py | 13 +- backend/cases/tests/test_csat.py | 26 +- backend/cases/tests/test_custom_fields.py | 16 +- backend/cases/tests/test_escalation.py | 8 +- backend/cases/tests/test_import.py | 60 ++-- backend/cases/tests/test_inbound_email.py | 6 +- backend/cases/tests/test_internal_notes.py | 7 +- backend/cases/tests/test_kb_suggestions.py | 12 +- backend/cases/tests/test_parent_child.py | 32 +- backend/cases/tests/test_reopen.py | 4 +- backend/cases/tests/test_sla_pause.py | 1 - backend/cases/tests/test_solutions.py | 9 +- backend/cases/tests/test_time_tracking.py | 29 +- backend/cases/tests/test_watchers.py | 53 +-- backend/cases/tests/test_watchers_mentions.py | 52 +-- backend/cases/time_views.py | 28 +- backend/cases/unmerge_views.py | 4 +- backend/common/app_urls/__init__.py | 4 +- backend/common/audit_log.py | 2 +- backend/common/base.py | 46 ++- backend/common/custom_fields.py | 4 +- backend/common/external_auth.py | 4 +- .../common/management/commands/seed_data.py | 11 +- backend/common/middleware/get_company.py | 13 +- .../migrations/0014_magic_link_token.py | 2 +- ...activity_metadata_alter_activity_action.py | 46 ++- .../0016_add_comment_is_internal.py | 16 +- .../migrations/0017_customfielddefinition.py | 137 ++++++-- .../migrations/0018_add_notification.py | 124 +++++-- .../migrations/0019_activity_analytics_idx.py | 1 - .../migrations/0020_org_csat_enabled.py | 7 +- .../0021_org_auto_close_children.py | 1 - .../migrations/0022_alter_activity_action.py | 44 ++- ...lter_customfielddefinition_target_model.py | 20 +- ...lter_customfielddefinition_target_model.py | 22 +- .../migrations/0025_magic_link_token_otp.py | 1 - .../common/migrations/0026_add_user_name.py | 11 +- .../migrations/0027_personalaccesstoken.py | 101 ++++-- .../migrations/0029_delete_sessiontoken.py | 17 +- .../migrations/0030_portalaccesstoken.py | 54 ++- .../migrations/0031_backfill_portal_tokens.py | 1 - .../common/migrations/0032_vertical_packs.py | 125 +++++-- .../0033_alter_securityauditlog_event_type.py | 1 - .../common/migrations/0037_org_timezone.py | 8 +- backend/common/models.py | 3 +- backend/common/packs/applier.py | 38 +- backend/common/packs/schema.py | 157 +++++++-- backend/common/pat_auth.py | 2 +- backend/common/scopes.py | 8 +- backend/common/search_views.py | 12 +- backend/common/status.py | 1 - backend/common/tasks.py | 14 +- backend/common/tests/test_audit_log.py | 8 +- backend/common/tests/test_auth.py | 108 ++++-- .../tests/test_base_model_audit_stamps.py | 204 +++++++++++ backend/common/tests/test_comment_authz.py | 5 +- backend/common/tests/test_custom_fields.py | 12 +- .../common/tests/test_docs_api_endpoints.py | 13 +- .../test_docs_build_excludes_internal.py | 3 +- .../tests/test_docs_environment_variables.py | 5 +- backend/common/tests/test_documents.py | 16 +- backend/common/tests/test_documents_access.py | 8 +- .../common/tests/test_duplicate_detection.py | 86 ++--- backend/common/tests/test_email_links.py | 4 +- backend/common/tests/test_external_auth.py | 20 +- backend/common/tests/test_logout.py | 24 +- backend/common/tests/test_magic_link.py | 20 +- .../common/tests/test_malformed_id_filters.py | 4 +- .../common/tests/test_malformed_id_routes.py | 4 +- backend/common/tests/test_models.py | 1 - backend/common/tests/test_multitenancy.py | 1 - backend/common/tests/test_notifications.py | 19 +- backend/common/tests/test_organizations.py | 13 +- backend/common/tests/test_packs_api.py | 4 +- backend/common/tests/test_packs_applier.py | 12 +- .../common/tests/test_packs_sample_data.py | 9 +- backend/common/tests/test_pat_api.py | 11 +- backend/common/tests/test_pat_auth.py | 16 +- backend/common/tests/test_pat_integration.py | 4 +- backend/common/tests/test_pat_model.py | 6 +- backend/common/tests/test_pat_org_api.py | 12 +- .../tests/test_pat_scope_enforcement.py | 14 +- .../common/tests/test_rls_pipeline_tables.py | 4 +- backend/common/tests/test_scopes.py | 14 +- backend/common/tests/test_search.py | 8 +- backend/common/tests/test_teams.py | 4 +- backend/common/tests/test_timezone_dates.py | 4 +- backend/common/tests/test_today.py | 4 +- backend/common/tests/test_users.py | 20 +- backend/common/urls.py | 2 +- backend/common/utils.py | 2 +- backend/common/validators.py | 5 +- backend/common/views/auth_views.py | 16 +- backend/common/views/document_views.py | 3 +- backend/common/views/pat_views.py | 4 +- backend/common/views/settings_views.py | 1 - backend/common/views/team_views.py | 1 - backend/common/views/user_views.py | 9 +- backend/conftest.py | 4 +- backend/contacts/import_views.py | 1 - .../contacts/migrations/0011_inbound_email.py | 12 +- .../migrations/0012_contact_custom_fields.py | 13 +- .../migrations/0013_contact_is_sample.py | 12 +- backend/contacts/serializer.py | 1 - backend/contacts/services/csv_import.py | 27 +- backend/contacts/tests/test_contacts_api.py | 1 - backend/contacts/tests/test_custom_fields.py | 8 +- backend/contacts/tests/test_import.py | 57 ++- backend/contacts/views.py | 7 +- backend/crm/settings.py | 14 +- backend/invoices/api_views.py | 6 +- ...neitem_invoicetemplate_payment_and_more.py | 3 +- ...mail_estimate_accepted_by_name_and_more.py | 35 +- backend/invoices/models.py | 7 +- backend/invoices/permissions.py | 1 - backend/invoices/seed.py | 14 +- backend/invoices/serializer.py | 3 +- backend/invoices/tasks.py | 16 +- backend/invoices/tests/test_client_links.py | 5 +- backend/invoices/tests/test_custom_fields.py | 8 +- .../test_estimate_recurring_custom_fields.py | 6 +- .../tests/test_estimate_status_authz.py | 2 +- .../tests/test_invoice_object_permissions.py | 6 +- backend/invoices/tests/test_invoices_api.py | 3 +- .../tests/test_line_item_product_org.py | 5 +- backend/invoices/tests/test_portal_rls.py | 3 +- .../invoices/tests/test_template_editor.py | 10 +- backend/leads/forms.py | 4 +- .../0011_add_enterprise_constraints.py | 2 +- .../migrations/0012_lead_kanban_pipeline.py | 3 +- .../migrations/0013_lead_custom_fields.py | 13 +- .../leads/migrations/0014_lead_is_sample.py | 12 +- .../migrations/0015_alter_lead_is_sample.py | 12 +- .../0016_alter_leadpipeline_is_default.py | 1 - backend/leads/models.py | 4 +- backend/leads/tasks.py | 4 +- .../tests/test_attachment_delete_authz.py | 8 +- backend/leads/tests/test_custom_fields.py | 20 +- .../tests/test_lead_access_and_totals.py | 28 +- .../tests/test_lead_status_transitions.py | 4 +- backend/leads/tests/test_lead_upload_authz.py | 8 +- backend/leads/tests/test_leads_api.py | 79 ++--- backend/leads/tests/test_leads_kanban.py | 20 +- backend/leads/urls.py | 18 +- backend/leads/views/kanban_views.py | 6 +- backend/leads/views/lead_interactions.py | 4 +- backend/leads/views/lead_views.py | 24 +- backend/macros/migrations/0001_initial.py | 1 - backend/macros/serializers.py | 2 +- .../0006_add_opportunity_line_item.py | 5 +- ...opportunity_opportu_456e8f_idx_and_more.py | 1 + .../migrations/0008_add_stage_aging.py | 118 +++++-- .../opportunity/migrations/0009_salesgoal.py | 155 ++++++-- .../0010_opportunity_custom_fields.py | 13 +- .../migrations/0014_opportunity_is_sample.py | 12 +- backend/opportunity/models.py | 33 +- backend/opportunity/serializer.py | 26 +- backend/opportunity/tasks.py | 22 +- .../tests/test_attachment_delete_authz.py | 16 +- .../opportunity/tests/test_custom_fields.py | 4 +- .../tests/test_deal_access_and_totals.py | 4 +- backend/opportunity/tests/test_deal_aging.py | 12 +- .../tests/test_opportunities_api.py | 137 +++----- backend/opportunity/tests/test_sales_goals.py | 11 +- backend/opportunity/views/aging_views.py | 21 +- backend/opportunity/views/goal_views.py | 8 +- backend/opportunity/views/kanban_views.py | 59 ++-- .../opportunity/views/opportunity_views.py | 27 +- backend/orders/admin.py | 8 +- backend/orders/migrations/0001_initial.py | 4 +- ..._null_with_default_empty_on_char_fields.py | 1 - ...order_orders_org_id_9026a7_idx_and_more.py | 1 - ...order_orders_org_id_9026a7_idx_and_more.py | 1 - backend/orders/models.py | 1 - backend/orders/tests/test_models.py | 21 +- backend/scripts/generate_endpoint_index.py | 8 +- backend/tasks/access.py | 3 +- backend/tasks/migrations/0009_task_kanban.py | 3 +- .../migrations/0011_task_custom_fields.py | 1 - .../tasks/migrations/0012_task_is_sample.py | 12 +- .../0013_alter_taskpipeline_is_default.py | 1 - backend/tasks/models.py | 1 - backend/tasks/tests/test_custom_fields.py | 16 +- backend/tasks/tests/test_task_access.py | 2 +- backend/tasks/tests/test_tasks_kanban.py | 32 +- backend/tasks/urls.py | 2 +- backend/tasks/views/kanban_views.py | 4 +- backend/tasks/views/task_views.py | 18 +- docs/contributing/code-style.md | 71 ++-- .../api/cases/analytics/[metric]/+server.js | 32 +- .../cases/analytics/[metric]/server.test.js | 141 ++++++++ 254 files changed, 4180 insertions(+), 2044 deletions(-) create mode 100644 backend/common/tests/test_base_model_audit_stamps.py create mode 100644 frontend/src/routes/api/cases/analytics/[metric]/server.test.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1beab238..2db380c8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -192,23 +192,32 @@ jobs: DBPORT: '5432' run: uv run pytest --no-cov --tb=short -q -m postgres_only - # Pyflakes rules only: undefined names, unused imports and variables, - # redefinitions, star imports. These catch defects rather than style. - # F811 is how `ActivitySerializer` came to be declared twice, with the - # second silently shadowing the first for both of its consumers, and the - # unused-import rule is what found three imports left behind by the - # timezone sweep plus two `# noqa` comments that suppressed nothing. + # Bare `ruff check .`, so the gate is whatever `backend/ruff.toml` selects + # (E, F, I with E501 ignored) rather than a CLI override that can silently + # drift from the file every editor and pre-commit hook reads. # - # `E` and `I` stay out for now. They are style, and the 118 I001 findings - # are a whole-tree import reorder that belongs in its own change rather - # than riding along with whatever else is in flight. + # F is the half that catches defects rather than style: F811 is how + # `ActivitySerializer` came to be declared twice, with the second silently + # shadowing the first for both of its consumers, and F401 found three + # imports left behind by the timezone sweep plus two `# noqa` comments + # that suppressed nothing. # # `uv run`, not `uvx`: ruff is a locked dev dependency, so CI and a # developer's machine check against the same version and a ruff release # cannot turn this red overnight. - - name: Lint (pyflakes rules) + - name: Lint working-directory: backend - run: uv run ruff check --select F . + run: uv run ruff check . + + # `ruff format` rather than black: it is a reimplementation of black with + # the same output, `ruff.toml` already carried the `[format]` section, and + # one tool cannot disagree with itself the way black and ruff format can + # at the edges. `ruff check` above covers isort's job through the I rules. + # The tree was formatted in full when this step was added, so the gate + # starts green rather than as a warning nobody acts on. + - name: Format check + working-directory: backend + run: uv run ruff format --check . frontend-checks: runs-on: ubuntu-latest diff --git a/backend/README.md b/backend/README.md index 56302fd6..018d79e4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -237,10 +237,10 @@ RLS is configured in `common/rls/__init__.py`: from common.rls import RLS_CONFIG, get_enable_policy_sql # List of protected tables -tables = RLS_CONFIG['tables'] +tables = RLS_CONFIG["tables"] # Enable RLS on a table -cursor.execute(get_enable_policy_sql('my_table')) +cursor.execute(get_enable_policy_sql("my_table")) ``` #### Management Commands @@ -294,6 +294,7 @@ Background tasks don't go through middleware, so set RLS context manually: ```python from common.tasks import set_rls_context + @app.task def my_background_task(data_id, org_id): set_rls_context(org_id) # Required! @@ -365,16 +366,19 @@ backend/ ### Code Quality ```bash -# Format code -uv run black . +# Lint (E, F and I as backend/ruff.toml selects them) +uv run ruff check . -# Sort imports -uv run isort . +# Format the tree. `ruff format` is black's output from one tool, and ruff's +# I rules do isort's job, so neither black nor isort is installed here. +uv run ruff format . # Run tests uv run pytest ``` +CI runs `ruff check .` and `ruff format --check .` as hard steps, so both must pass. + ### Managing Dependencies ```bash @@ -405,6 +409,7 @@ uv lock --upgrade from common.base import BaseModel from common.models import Org + class MyModel(BaseModel): org = models.ForeignKey(Org, on_delete=models.CASCADE) # ... other fields diff --git a/backend/accounts/migrations/0007_account_custom_fields.py b/backend/accounts/migrations/0007_account_custom_fields.py index 384a483e..0ace515b 100644 --- a/backend/accounts/migrations/0007_account_custom_fields.py +++ b/backend/accounts/migrations/0007_account_custom_fields.py @@ -2,15 +2,18 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('accounts', '0006_add_enterprise_constraints'), + ("accounts", "0006_add_enterprise_constraints"), ] operations = [ migrations.AddField( - model_name='account', - name='custom_fields', - field=models.JSONField(blank=True, default=dict, help_text='Per-org schema extension; values are validated against common.CustomFieldDefinition.'), + model_name="account", + name="custom_fields", + field=models.JSONField( + blank=True, + default=dict, + help_text="Per-org schema extension; values are validated against common.CustomFieldDefinition.", + ), ), ] diff --git a/backend/accounts/migrations/0008_account_is_sample.py b/backend/accounts/migrations/0008_account_is_sample.py index 13d8db1e..b922d8c4 100644 --- a/backend/accounts/migrations/0008_account_is_sample.py +++ b/backend/accounts/migrations/0008_account_is_sample.py @@ -4,15 +4,17 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('accounts', '0007_account_custom_fields'), + ("accounts", "0007_account_custom_fields"), ] operations = [ migrations.AddField( - model_name='account', - name='is_sample', - field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + model_name="account", + name="is_sample", + field=models.BooleanField( + default=False, + help_text="True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.", + ), ), ] diff --git a/backend/accounts/models.py b/backend/accounts/models.py index 531554d7..db4876d9 100644 --- a/backend/accounts/models.py +++ b/backend/accounts/models.py @@ -10,7 +10,6 @@ from common.utils import COUNTRIES, CURRENCY_CODES, INDCHOICES from common.validators import flexible_phone_validator from contacts.models import Contact - # Cleanup notes: # - Removed 'created_on_arrow' property (frontend computes its own timestamps) # - Removed 'contact_values' property (unused) diff --git a/backend/accounts/serializer.py b/backend/accounts/serializer.py index 9ef34924..43efd0e8 100644 --- a/backend/accounts/serializer.py +++ b/backend/accounts/serializer.py @@ -12,7 +12,6 @@ from common.serializer import ( ) from contacts.serializer import ContactSerializer - # Note: Removed unused serializer properties that were computed but never used by frontend: # - get_team_users, get_team_and_assigned_users, get_assigned_users_not_in_teams # - created_on_arrow (frontend computes its own humanized timestamps) diff --git a/backend/accounts/tests/test_account_access_and_rollups.py b/backend/accounts/tests/test_account_access_and_rollups.py index d9c8e1b4..bd866dd5 100644 --- a/backend/accounts/tests/test_account_access_and_rollups.py +++ b/backend/accounts/tests/test_account_access_and_rollups.py @@ -687,9 +687,7 @@ class TestSidePayloadsRespectRole: ): from contacts.models import Contact - mine = Contact.objects.create( - first_name="Mine", last_name="Contact", org=org_a - ) + mine = Contact.objects.create(first_name="Mine", last_name="Contact", org=org_a) mine.assigned_to.add(user_profile) theirs = Contact.objects.create( first_name="Theirs", last_name="Contact", org=org_a diff --git a/backend/accounts/tests/test_accounts_api.py b/backend/accounts/tests/test_accounts_api.py index c3d1f226..46c3de5e 100644 --- a/backend/accounts/tests/test_accounts_api.py +++ b/backend/accounts/tests/test_accounts_api.py @@ -943,18 +943,20 @@ class TestAccountSerializerValidation: def test_email_serializer_validate_message_body_unbalanced_open(self): """EmailSerializer.validate_message_body with unbalanced open bracket.""" - from accounts.serializer import EmailSerializer from rest_framework import serializers as drf_serializers + from accounts.serializer import EmailSerializer + serializer = EmailSerializer() with pytest.raises(drf_serializers.ValidationError): serializer.validate_message_body("Hello {name, welcome!") def test_email_serializer_validate_message_body_unbalanced_close(self): """EmailSerializer.validate_message_body with close bracket before open.""" - from accounts.serializer import EmailSerializer from rest_framework import serializers as drf_serializers + from accounts.serializer import EmailSerializer + serializer = EmailSerializer() with pytest.raises(drf_serializers.ValidationError): serializer.validate_message_body("Hello }name{, welcome!") diff --git a/backend/accounts/tests/test_custom_fields.py b/backend/accounts/tests/test_custom_fields.py index 029932ea..a09ec159 100644 --- a/backend/accounts/tests/test_custom_fields.py +++ b/backend/accounts/tests/test_custom_fields.py @@ -25,9 +25,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) def _make_tier_def(org, **overrides): @@ -189,9 +187,7 @@ class TestAccountDetailResponse: keys = [d["key"] for d in body["custom_field_definitions"]] assert "tier" in keys - def test_detail_excludes_inactive_definitions( - self, admin_client, account_a, org_a - ): + def test_detail_excludes_inactive_definitions(self, admin_client, account_a, org_a): _make_tier_def(org_a) _make_tier_def( org_a, key="legacy", field_type="text", options=None, is_active=False diff --git a/backend/accounts/views.py b/backend/accounts/views.py index cd1426f7..62d2999c 100644 --- a/backend/accounts/views.py +++ b/backend/accounts/views.py @@ -24,18 +24,10 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response - -from common.custom_fields import validate_payload as validate_custom_fields_payload -from common.lookups import get_scoped_or_404 -from common.permissions import HasOrgContext, is_org_admin from rest_framework.views import APIView from accounts import swagger_params from accounts.models import Account -from cases.models import Case -from cases.workflow import TERMINAL_STATUSES -from invoices.models import UNPAID_STATUSES, Invoice -from opportunity.workflow import CLOSED_STAGES from accounts.serializer import ( AccountCommentEditSwaggerSerializer, AccountCreateSerializer, @@ -46,10 +38,12 @@ from accounts.serializer import ( EmailWriteSerializer, TagsSerializer, ) -from common.utils import create_attachment, get_or_create_tags, handle_m2m_assignment -from common.validators import date_param, payload_id_list, uuid_list_param from accounts.tasks import send_email, send_email_to_assigned_user +from cases.models import Case from cases.serializer import CaseSerializer +from cases.workflow import TERMINAL_STATUSES +from common.custom_fields import validate_payload as validate_custom_fields_payload +from common.lookups import get_scoped_or_404 from common.models import ( Attachments, Comment, @@ -58,6 +52,7 @@ from common.models import ( Tags, Teams, ) +from common.permissions import HasOrgContext, is_org_admin from common.serializer import ( AttachmentsSerializer, CommentSerializer, @@ -72,14 +67,20 @@ from common.utils import ( INDCHOICES, PRIORITY_CHOICE, STATUS_CHOICE, + create_attachment, + get_or_create_tags, + handle_m2m_assignment, ) +from common.validators import date_param, payload_id_list, uuid_list_param from contacts.models import Contact from contacts.serializer import ContactSerializer +from invoices.models import UNPAID_STATUSES, Invoice from invoices.serializer import InvoiceListSerializer from leads.models import Lead from leads.serializer import LeadSerializer from opportunity.models import SOURCES, STAGES, Opportunity from opportunity.serializer import OpportunitySerializer +from opportunity.workflow import CLOSED_STAGES from tasks.serializer import TaskSerializer # Money and counts the account page shows about an account: what it has been @@ -212,9 +213,7 @@ class AccountsListView(APIView, LimitOffsetPagination): queryset = queryset.filter(tags__id__in=tags).distinct() assigned_to = uuid_list_param(params, "assigned_to") if assigned_to: - queryset = queryset.filter( - assigned_to__id__in=assigned_to - ).distinct() + queryset = queryset.filter(assigned_to__id__in=assigned_to).distinct() if params.get("search"): queryset = queryset.filter(name__icontains=params.get("search")) created_at_gte = date_param(params, "created_at__gte") diff --git a/backend/business_hours/calendar.py b/backend/business_hours/calendar.py index 401f03a2..f755281a 100644 --- a/backend/business_hours/calendar.py +++ b/backend/business_hours/calendar.py @@ -36,9 +36,7 @@ def _next_midnight(tz: ZoneInfo, current_local: datetime) -> datetime: def _has_any_open_window(windows) -> bool: - return any( - o is not None and c is not None and c > o for (o, c) in windows - ) + return any(o is not None and c is not None and c > o for (o, c) in windows) def add_business_hours(start_dt: datetime, hours: float, calendar) -> datetime: diff --git a/backend/business_hours/migrations/0001_initial.py b/backend/business_hours/migrations/0001_initial.py index 6d14b8b5..7e7bf5be 100644 --- a/backend/business_hours/migrations/0001_initial.py +++ b/backend/business_hours/migrations/0001_initial.py @@ -1,12 +1,13 @@ # Generated by Django 6.0.5 on 2026-05-09 21:10 -import business_hours.models import datetime -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models +import business_hours.models from common.rls import get_disable_policy_sql, get_enable_policy_sql @@ -69,87 +70,202 @@ def remove_default_calendars(apps, schema_editor): class Migration(migrations.Migration): - initial = True # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('common', '0018_add_notification'), + ("common", "0018_add_notification"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='BusinessCalendar', + name="BusinessCalendar", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(default='Default', max_length=100)), - ('timezone', models.CharField(default='UTC', help_text='IANA timezone (e.g. America/New_York).', max_length=64, validators=[business_hours.models._validate_iana_tz])), - ('is_default', models.BooleanField(default=True)), - ('monday_open', models.TimeField(blank=True, null=True)), - ('monday_close', models.TimeField(blank=True, null=True)), - ('tuesday_open', models.TimeField(blank=True, null=True)), - ('tuesday_close', models.TimeField(blank=True, null=True)), - ('wednesday_open', models.TimeField(blank=True, null=True)), - ('wednesday_close', models.TimeField(blank=True, null=True)), - ('thursday_open', models.TimeField(blank=True, null=True)), - ('thursday_close', models.TimeField(blank=True, null=True)), - ('friday_open', models.TimeField(blank=True, null=True)), - ('friday_close', models.TimeField(blank=True, null=True)), - ('saturday_open', models.TimeField(blank=True, null=True)), - ('saturday_close', models.TimeField(blank=True, null=True)), - ('sunday_open', models.TimeField(blank=True, null=True)), - ('sunday_close', models.TimeField(blank=True, null=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_calendars', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(default="Default", max_length=100)), + ( + "timezone", + models.CharField( + default="UTC", + help_text="IANA timezone (e.g. America/New_York).", + max_length=64, + validators=[business_hours.models._validate_iana_tz], + ), + ), + ("is_default", models.BooleanField(default=True)), + ("monday_open", models.TimeField(blank=True, null=True)), + ("monday_close", models.TimeField(blank=True, null=True)), + ("tuesday_open", models.TimeField(blank=True, null=True)), + ("tuesday_close", models.TimeField(blank=True, null=True)), + ("wednesday_open", models.TimeField(blank=True, null=True)), + ("wednesday_close", models.TimeField(blank=True, null=True)), + ("thursday_open", models.TimeField(blank=True, null=True)), + ("thursday_close", models.TimeField(blank=True, null=True)), + ("friday_open", models.TimeField(blank=True, null=True)), + ("friday_close", models.TimeField(blank=True, null=True)), + ("saturday_open", models.TimeField(blank=True, null=True)), + ("saturday_close", models.TimeField(blank=True, null=True)), + ("sunday_open", models.TimeField(blank=True, null=True)), + ("sunday_close", models.TimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="business_calendars", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Business Calendar', - 'verbose_name_plural': 'Business Calendars', - 'db_table': 'business_calendar', - 'ordering': ('-is_default', 'name'), + "verbose_name": "Business Calendar", + "verbose_name_plural": "Business Calendars", + "db_table": "business_calendar", + "ordering": ("-is_default", "name"), }, ), migrations.CreateModel( - name='BusinessHoliday', + name="BusinessHoliday", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('date', models.DateField()), - ('name', models.CharField(max_length=100)), - ('calendar', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='holidays', to='business_hours.businesscalendar')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_holidays', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("date", models.DateField()), + ("name", models.CharField(max_length=100)), + ( + "calendar", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="holidays", + to="business_hours.businesscalendar", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="business_holidays", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Business Holiday', - 'verbose_name_plural': 'Business Holidays', - 'db_table': 'business_holiday', - 'ordering': ('date',), + "verbose_name": "Business Holiday", + "verbose_name_plural": "Business Holidays", + "db_table": "business_holiday", + "ordering": ("date",), }, ), migrations.AddIndex( - model_name='businesscalendar', - index=models.Index(fields=['org', 'is_default'], name='business_ca_org_id_e518f3_idx'), + model_name="businesscalendar", + index=models.Index( + fields=["org", "is_default"], name="business_ca_org_id_e518f3_idx" + ), ), migrations.AddConstraint( - model_name='businesscalendar', - constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('org',), name='uniq_default_business_calendar_per_org'), + model_name="businesscalendar", + constraint=models.UniqueConstraint( + condition=models.Q(("is_default", True)), + fields=("org",), + name="uniq_default_business_calendar_per_org", + ), ), migrations.AddIndex( - model_name='businessholiday', - index=models.Index(fields=['calendar', 'date'], name='business_ho_calenda_8e023f_idx'), + model_name="businessholiday", + index=models.Index( + fields=["calendar", "date"], name="business_ho_calenda_8e023f_idx" + ), ), migrations.AddConstraint( - model_name='businessholiday', - constraint=models.UniqueConstraint(fields=('calendar', 'date'), name='uniq_business_holiday_per_calendar_date'), + model_name="businessholiday", + constraint=models.UniqueConstraint( + fields=("calendar", "date"), + name="uniq_business_holiday_per_calendar_date", + ), ), migrations.RunPython(enable_rls, reverse_code=disable_rls), migrations.RunPython( diff --git a/backend/business_hours/serializers.py b/backend/business_hours/serializers.py index 960b0811..6e977ba8 100644 --- a/backend/business_hours/serializers.py +++ b/backend/business_hours/serializers.py @@ -7,7 +7,6 @@ from rest_framework import serializers from business_hours.models import BusinessCalendar, BusinessHoliday - _WEEKDAY_FIELDS = ( "monday_open", "monday_close", @@ -59,7 +58,9 @@ class BusinessCalendarSerializer(serializers.ModelSerializer): open_field = _WEEKDAY_FIELDS[i] close_field = _WEEKDAY_FIELDS[i + 1] open_val = attrs.get(open_field, getattr(self.instance, open_field, None)) - close_val = attrs.get(close_field, getattr(self.instance, close_field, None)) + close_val = attrs.get( + close_field, getattr(self.instance, close_field, None) + ) if (open_val is None) != (close_val is None): raise serializers.ValidationError( {open_field: "Open and close must be both set or both null."} diff --git a/backend/business_hours/tests/test_calendar.py b/backend/business_hours/tests/test_calendar.py index ff87d812..030c0aed 100644 --- a/backend/business_hours/tests/test_calendar.py +++ b/backend/business_hours/tests/test_calendar.py @@ -7,7 +7,6 @@ import pytest from business_hours.calendar import add_business_hours - _UTC = ZoneInfo("UTC") _NY = ZoneInfo("America/New_York") diff --git a/backend/cases/approvals.py b/backend/cases/approvals.py index 2b889143..49531616 100644 --- a/backend/cases/approvals.py +++ b/backend/cases/approvals.py @@ -22,7 +22,6 @@ from common.base import BaseModel from common.models import Org, Profile, Teams from common.utils import CASE_TYPE, PRIORITY_CHOICE - # Approver roles. Mirrors the spec's ``ADMIN``/``MANAGER`` choices even though # the project's ``Profile.role`` only knows ``ADMIN``/``USER`` today, keeping # ``MANAGER`` as a reserved value avoids a future schema change when the role @@ -32,9 +31,7 @@ APPROVER_ROLE_CHOICES = ( ("MANAGER", "Manager"), ) -TRIGGER_EVENT_CHOICES = ( - ("pre_close", "Pre-Close"), -) +TRIGGER_EVENT_CHOICES = (("pre_close", "Pre-Close"),) APPROVAL_STATE_CHOICES = ( ("pending", "Pending"), @@ -139,9 +136,7 @@ def find_matching_rule(case, trigger_event: str = "pre_close"): class Approval(BaseModel): """A single approval request bound to one Case + ApprovalRule.""" - org = models.ForeignKey( - Org, on_delete=models.CASCADE, related_name="approvals" - ) + org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="approvals") case = models.ForeignKey( "cases.Case", on_delete=models.CASCADE, related_name="approvals" ) @@ -174,9 +169,7 @@ class Approval(BaseModel): indexes = [ models.Index(fields=["org", "state"], name="approval_org_state_idx"), models.Index(fields=["case", "state"], name="approval_case_state_idx"), - models.Index( - fields=["approver", "state"], name="approval_approver_idx" - ), + models.Index(fields=["approver", "state"], name="approval_approver_idx"), ] def __str__(self): diff --git a/backend/cases/bulk_views.py b/backend/cases/bulk_views.py index 17e949ab..4e52275e 100644 --- a/backend/cases/bulk_views.py +++ b/backend/cases/bulk_views.py @@ -91,7 +91,7 @@ class BulkDeleteCasesView(APIView): ) org = request.profile.org # Snapshot the cases so we can emit Activity rows after the bulk update - #; queryset.update() bypasses signals. + # ; queryset.update() bypasses signals. qs = Case.objects.filter(pk__in=ids, org=org, is_active=True) target_cases = list(qs.values("id", "name")) deleted_count = qs.update(is_active=False) diff --git a/backend/cases/import_views.py b/backend/cases/import_views.py index 5096382c..8c6723e6 100644 --- a/backend/cases/import_views.py +++ b/backend/cases/import_views.py @@ -16,7 +16,6 @@ from rest_framework.views import APIView from cases.services.csv_import import commit_rows, parse_and_validate from common.permissions import HasOrgContext - MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 5 MB; matches the UI hint diff --git a/backend/cases/inbound/contacts.py b/backend/cases/inbound/contacts.py index 325655c7..9a337c0b 100644 --- a/backend/cases/inbound/contacts.py +++ b/backend/cases/inbound/contacts.py @@ -8,6 +8,7 @@ import logging from typing import Optional from contacts.models import Contact + from .parser import ParsedEmail logger = logging.getLogger(__name__) diff --git a/backend/cases/inbound/parser.py b/backend/cases/inbound/parser.py index c68f6e41..94b19525 100644 --- a/backend/cases/inbound/parser.py +++ b/backend/cases/inbound/parser.py @@ -146,7 +146,9 @@ def _extract_attachments(msg: StdlibEmailMessage) -> list[ParsedAttachment]: attachments.append( ParsedAttachment( filename=part.get_filename() or "attachment", - content_type=(part.get_content_type() or "application/octet-stream").lower(), + content_type=( + part.get_content_type() or "application/octet-stream" + ).lower(), payload=payload, ) ) @@ -157,7 +159,9 @@ def _is_bounce(msg: StdlibEmailMessage) -> bool: """True if the MIME tree looks like a delivery-status bounce.""" ctype = (msg.get_content_type() or "").lower() if ctype == "multipart/report": - report_type = (msg.get_param("report-type", header="content-type") or "").lower() + report_type = ( + msg.get_param("report-type", header="content-type") or "" + ).lower() if report_type == "delivery-status": return True return False diff --git a/backend/cases/inbound/sns.py b/backend/cases/inbound/sns.py index f135bdd3..4aa6be82 100644 --- a/backend/cases/inbound/sns.py +++ b/backend/cases/inbound/sns.py @@ -89,9 +89,7 @@ _OPENER = build_opener( # before a trailing newline, so `match` would accept "sns.amazonaws.com\n". # The `^`/`$` anchors are redundant under `fullmatch` but are kept so the # pattern stays anchored if anyone switches the call back. -_SIGNING_HOST_RE = re.compile( - r"^sns(?:\.[a-z0-9-]+)?\.amazonaws\.com(?:\.cn)?$" -) +_SIGNING_HOST_RE = re.compile(r"^sns(?:\.[a-z0-9-]+)?\.amazonaws\.com(?:\.cn)?$") # Headers required for the canonical signing string for each message type. @@ -196,7 +194,11 @@ def verify_sns_message( the network. """ msg_type = payload.get("Type") - if msg_type not in {"Notification", "SubscriptionConfirmation", "UnsubscribeConfirmation"}: + if msg_type not in { + "Notification", + "SubscriptionConfirmation", + "UnsubscribeConfirmation", + }: raise SNSVerificationError(f"Unknown SNS Type: {msg_type!r}") signature_b64 = payload.get("Signature") @@ -253,7 +255,9 @@ def confirm_subscription(payload: dict, *, fetch=None, timeout: float = 5.0) -> if not url: raise SNSVerificationError("SubscriptionConfirmation missing SubscribeURL") parsed = urlparse(url) - if parsed.scheme != "https" or not _SIGNING_HOST_RE.fullmatch(parsed.hostname or ""): + if parsed.scheme != "https" or not _SIGNING_HOST_RE.fullmatch( + parsed.hostname or "" + ): raise SNSVerificationError(f"SubscribeURL not on AWS SNS host: {url!r}") # Resolved at call time, not bound as a default, so the module-level opener # stays the single place redirect policy is decided. diff --git a/backend/cases/inbound/spam.py b/backend/cases/inbound/spam.py index 27f66f3d..ae3b7d54 100644 --- a/backend/cases/inbound/spam.py +++ b/backend/cases/inbound/spam.py @@ -9,7 +9,6 @@ from __future__ import annotations from .parser import ParsedEmail - # Set as a constant so the test suite can introspect the policy. DROP_PRECEDENCE_VALUES = {"bulk", "list", "junk"} diff --git a/backend/cases/inbound/threading.py b/backend/cases/inbound/threading.py index e3d3b30f..acd1285c 100644 --- a/backend/cases/inbound/threading.py +++ b/backend/cases/inbound/threading.py @@ -8,6 +8,7 @@ from typing import Optional from django.db import connection from cases.models import Case, EmailMessage + from .parser import ParsedEmail # Subject-line fallback: `[Case #]` where short-id is the first diff --git a/backend/cases/kanban_views.py b/backend/cases/kanban_views.py index 8f3c120e..16187e57 100644 --- a/backend/cases/kanban_views.py +++ b/backend/cases/kanban_views.py @@ -23,8 +23,8 @@ from cases.serializer import ( CaseStageSerializer, ) from common.permissions import HasOrgContext, is_org_admin -from common.validators import date_param, uuid_param from common.utils import STATUS_CHOICE +from common.validators import date_param, uuid_param class CaseKanbanView(APIView): diff --git a/backend/cases/kb_views.py b/backend/cases/kb_views.py index 61f61e4e..17e00e4c 100644 --- a/backend/cases/kb_views.py +++ b/backend/cases/kb_views.py @@ -17,7 +17,6 @@ from rest_framework.views import APIView from cases.models import Case, Solution from common.permissions import HasOrgContext - _SNIPPET_MAX = 200 _DEFAULT_LIMIT = 5 _MAX_LIMIT = 20 @@ -86,10 +85,9 @@ class SolutionSuggestionsView(APIView): published = Solution.objects.filter(org=org, is_published=True) if q: - results = ( - published.filter(Q(title__icontains=q) | Q(description__icontains=q)) - .order_by("-updated_at")[:limit] - ) + results = published.filter( + Q(title__icontains=q) | Q(description__icontains=q) + ).order_by("-updated_at")[:limit] else: # Seed from the case so the panel is useful on first focus. If # there are no seed terms, or the seed-term filter produces zero diff --git a/backend/cases/merge_views.py b/backend/cases/merge_views.py index 5dbe68e2..5507728e 100644 --- a/backend/cases/merge_views.py +++ b/backend/cases/merge_views.py @@ -68,8 +68,7 @@ class CaseMergeView(APIView): ordered_ids = sorted([pk, into_id]) with transaction.atomic(): locked = list( - Case.objects.select_for_update() - .filter(id__in=ordered_ids, org=org) + Case.objects.select_for_update().filter(id__in=ordered_ids, org=org) ) by_id = {str(c.id): c for c in locked} source = by_id.get(str(pk)) @@ -140,20 +139,20 @@ class CaseMergeView(APIView): ).values_list("id", flat=True) ) moved_email_ids = list( - EmailMessage.objects.filter( - case=source, org=org - ).values_list("id", flat=True) + EmailMessage.objects.filter(case=source, org=org).values_list( + "id", flat=True + ) ) - comments_moved = Comment.objects.filter( - id__in=moved_comment_ids - ).update(object_id=target.id) + comments_moved = Comment.objects.filter(id__in=moved_comment_ids).update( + object_id=target.id + ) attachments_moved = Attachments.objects.filter( id__in=moved_attachment_ids ).update(object_id=target.id) - emails_moved = EmailMessage.objects.filter( - id__in=moved_email_ids - ).update(case=target) + emails_moved = EmailMessage.objects.filter(id__in=moved_email_ids).update( + case=target + ) # Inherit the duplicate's thread keys onto the primary so future # inbound emails to the old thread land on the primary. diff --git a/backend/cases/migrations/0009_case_kanban.py b/backend/cases/migrations/0009_case_kanban.py index 7fc3cb71..7ba7fdec 100644 --- a/backend/cases/migrations/0009_case_kanban.py +++ b/backend/cases/migrations/0009_case_kanban.py @@ -1,7 +1,8 @@ # Generated by Django 5.2.9 on 2025-12-09 10:01 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models diff --git a/backend/cases/migrations/0010_reopen_policy.py b/backend/cases/migrations/0010_reopen_policy.py index 18de119a..116e4027 100644 --- a/backend/cases/migrations/0010_reopen_policy.py +++ b/backend/cases/migrations/0010_reopen_policy.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-09 10:34 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -23,35 +24,100 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('cases', '0009_case_kanban'), - ('common', '0016_add_comment_is_internal'), + ("cases", "0009_case_kanban"), + ("common", "0016_add_comment_is_internal"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='ReopenPolicy', + name="ReopenPolicy", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('is_enabled', models.BooleanField(default=True)), - ('reopen_window_days', models.PositiveSmallIntegerField(default=7, help_text='Replies within this many days of closed_on reopen the case.')), - ('reopen_to_status', models.CharField(choices=[('New', 'New'), ('Assigned', 'Assigned'), ('Pending', 'Pending'), ('Closed', 'Closed'), ('Rejected', 'Rejected'), ('Duplicate', 'Duplicate')], default='Pending', help_text='Status the case flips to on reopen (must be non-terminal).', max_length=64)), - ('notify_assigned', models.BooleanField(default=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='reopen_policy', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("is_enabled", models.BooleanField(default=True)), + ( + "reopen_window_days", + models.PositiveSmallIntegerField( + default=7, + help_text="Replies within this many days of closed_on reopen the case.", + ), + ), + ( + "reopen_to_status", + models.CharField( + choices=[ + ("New", "New"), + ("Assigned", "Assigned"), + ("Pending", "Pending"), + ("Closed", "Closed"), + ("Rejected", "Rejected"), + ("Duplicate", "Duplicate"), + ], + default="Pending", + help_text="Status the case flips to on reopen (must be non-terminal).", + max_length=64, + ), + ), + ("notify_assigned", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="reopen_policy", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Reopen Policy', - 'verbose_name_plural': 'Reopen Policies', - 'db_table': 'reopen_policy', + "verbose_name": "Reopen Policy", + "verbose_name_plural": "Reopen Policies", + "db_table": "reopen_policy", }, ), migrations.RunPython(enable_rls, reverse_code=disable_rls), diff --git a/backend/cases/migrations/0011_case_custom_fields.py b/backend/cases/migrations/0011_case_custom_fields.py index d86bd912..61cbf896 100644 --- a/backend/cases/migrations/0011_case_custom_fields.py +++ b/backend/cases/migrations/0011_case_custom_fields.py @@ -4,15 +4,18 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('cases', '0010_reopen_policy'), + ("cases", "0010_reopen_policy"), ] operations = [ migrations.AddField( - model_name='case', - name='custom_fields', - field=models.JSONField(blank=True, default=dict, help_text='Per-org schema extension; values are validated against common.CustomFieldDefinition.'), + model_name="case", + name="custom_fields", + field=models.JSONField( + blank=True, + default=dict, + help_text="Per-org schema extension; values are validated against common.CustomFieldDefinition.", + ), ), ] diff --git a/backend/cases/migrations/0012_escalation.py b/backend/cases/migrations/0012_escalation.py index 7e34a87e..511de040 100644 --- a/backend/cases/migrations/0012_escalation.py +++ b/backend/cases/migrations/0012_escalation.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-09 11:26 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -23,51 +24,173 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('cases', '0011_case_custom_fields'), - ('common', '0017_customfielddefinition'), + ("cases", "0011_case_custom_fields"), + ("common", "0017_customfielddefinition"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='case', - name='escalation_count', + model_name="case", + name="escalation_count", field=models.PositiveSmallIntegerField(default=0), ), migrations.AddField( - model_name='case', - name='last_escalation_fired_at', - field=models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Last Escalation Fired At'), + model_name="case", + name="last_escalation_fired_at", + field=models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="Last Escalation Fired At", + ), ), migrations.CreateModel( - name='EscalationPolicy', + name="EscalationPolicy", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('priority', models.CharField(choices=[('Low', 'Low'), ('Normal', 'Normal'), ('High', 'High'), ('Urgent', 'Urgent')], max_length=64)), - ('first_response_action', models.CharField(choices=[('notify', 'Notify'), ('reassign', 'Reassign'), ('notify_and_reassign', 'Notify and reassign')], default='notify', max_length=32)), - ('resolution_action', models.CharField(choices=[('notify', 'Notify'), ('reassign', 'Reassign'), ('notify_and_reassign', 'Notify and reassign')], default='notify', max_length=32)), - ('is_active', models.BooleanField(default=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('first_response_target', models.ForeignKey(blank=True, help_text='Profile reassigned to and/or notified on first-response breach.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='first_response_escalation_policies', to='common.profile')), - ('notify_team', models.ForeignKey(blank=True, help_text="Optional team CC'd on either breach action.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalation_policies', to='common.teams')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='escalation_policies', to='common.org')), - ('resolution_target', models.ForeignKey(blank=True, help_text='Profile reassigned to and/or notified on resolution breach.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolution_escalation_policies', to='common.profile')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "priority", + models.CharField( + choices=[ + ("Low", "Low"), + ("Normal", "Normal"), + ("High", "High"), + ("Urgent", "Urgent"), + ], + max_length=64, + ), + ), + ( + "first_response_action", + models.CharField( + choices=[ + ("notify", "Notify"), + ("reassign", "Reassign"), + ("notify_and_reassign", "Notify and reassign"), + ], + default="notify", + max_length=32, + ), + ), + ( + "resolution_action", + models.CharField( + choices=[ + ("notify", "Notify"), + ("reassign", "Reassign"), + ("notify_and_reassign", "Notify and reassign"), + ], + default="notify", + max_length=32, + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "first_response_target", + models.ForeignKey( + blank=True, + help_text="Profile reassigned to and/or notified on first-response breach.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="first_response_escalation_policies", + to="common.profile", + ), + ), + ( + "notify_team", + models.ForeignKey( + blank=True, + help_text="Optional team CC'd on either breach action.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="escalation_policies", + to="common.teams", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="escalation_policies", + to="common.org", + ), + ), + ( + "resolution_target", + models.ForeignKey( + blank=True, + help_text="Profile reassigned to and/or notified on resolution breach.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="resolution_escalation_policies", + to="common.profile", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Escalation Policy', - 'verbose_name_plural': 'Escalation Policies', - 'db_table': 'escalation_policy', - 'ordering': ('priority',), - 'indexes': [models.Index(fields=['org', 'is_active'], name='escalation__org_id_4bf36d_idx')], - 'constraints': [models.UniqueConstraint(fields=('org', 'priority'), name='uniq_escalation_policy_per_org_priority')], + "verbose_name": "Escalation Policy", + "verbose_name_plural": "Escalation Policies", + "db_table": "escalation_policy", + "ordering": ("priority",), + "indexes": [ + models.Index( + fields=["org", "is_active"], + name="escalation__org_id_4bf36d_idx", + ) + ], + "constraints": [ + models.UniqueConstraint( + fields=("org", "priority"), + name="uniq_escalation_policy_per_org_priority", + ) + ], }, ), migrations.RunPython(enable_rls, reverse_code=disable_rls), diff --git a/backend/cases/migrations/0013_inbound_email.py b/backend/cases/migrations/0013_inbound_email.py index 2281d695..eb055c09 100644 --- a/backend/cases/migrations/0013_inbound_email.py +++ b/backend/cases/migrations/0013_inbound_email.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-09 12:03 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -25,82 +26,299 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('cases', '0012_escalation'), - ('common', '0017_customfielddefinition'), + ("cases", "0012_escalation"), + ("common", "0017_customfielddefinition"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='case', - name='external_thread_id', - field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + model_name="case", + name="external_thread_id", + field=models.CharField( + blank=True, db_index=True, max_length=255, null=True + ), ), migrations.CreateModel( - name='EmailMessage', + name="EmailMessage", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('direction', models.CharField(choices=[('inbound', 'Inbound'), ('outbound', 'Outbound')], default='inbound', max_length=16)), - ('message_id', models.CharField(db_index=True, max_length=512)), - ('in_reply_to', models.CharField(blank=True, db_index=True, default='', max_length=512)), - ('references', models.TextField(blank=True, default='', help_text='Whitespace-separated list of RFC-5322 Message-IDs from the References header.')), - ('from_address', models.EmailField(max_length=254)), - ('to_addresses', models.TextField(blank=True, default='')), - ('cc_addresses', models.TextField(blank=True, default='')), - ('subject', models.CharField(blank=True, default='', max_length=512)), - ('body_text', models.TextField(blank=True, default='')), - ('body_html', models.TextField(blank=True, default='')), - ('received_at', models.DateTimeField()), - ('drop_reason', models.CharField(blank=True, default='', help_text='If non-empty, the message was rejected (spam/bounce/etc.) before any Case was touched. `case` will be null in this case.', max_length=64)), - ('case', models.ForeignKey(blank=True, help_text='Null when the message was dropped (spam/bounce/auto-reply) but we still want an audit trail.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='email_messages', to='cases.case')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_messages', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "direction", + models.CharField( + choices=[("inbound", "Inbound"), ("outbound", "Outbound")], + default="inbound", + max_length=16, + ), + ), + ("message_id", models.CharField(db_index=True, max_length=512)), + ( + "in_reply_to", + models.CharField( + blank=True, db_index=True, default="", max_length=512 + ), + ), + ( + "references", + models.TextField( + blank=True, + default="", + help_text="Whitespace-separated list of RFC-5322 Message-IDs from the References header.", + ), + ), + ("from_address", models.EmailField(max_length=254)), + ("to_addresses", models.TextField(blank=True, default="")), + ("cc_addresses", models.TextField(blank=True, default="")), + ("subject", models.CharField(blank=True, default="", max_length=512)), + ("body_text", models.TextField(blank=True, default="")), + ("body_html", models.TextField(blank=True, default="")), + ("received_at", models.DateTimeField()), + ( + "drop_reason", + models.CharField( + blank=True, + default="", + help_text="If non-empty, the message was rejected (spam/bounce/etc.) before any Case was touched. `case` will be null in this case.", + max_length=64, + ), + ), + ( + "case", + models.ForeignKey( + blank=True, + help_text="Null when the message was dropped (spam/bounce/auto-reply) but we still want an audit trail.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="email_messages", + to="cases.case", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="email_messages", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Email Message', - 'verbose_name_plural': 'Email Messages', - 'db_table': 'email_message', - 'ordering': ('-received_at',), - 'indexes': [models.Index(fields=['org', '-received_at'], name='email_messa_org_id_631bc3_idx'), models.Index(fields=['case', '-received_at'], name='email_messa_case_id_bc66f7_idx'), models.Index(fields=['in_reply_to'], name='email_messa_in_repl_216d84_idx')], - 'constraints': [models.UniqueConstraint(fields=('org', 'message_id'), name='uniq_email_message_per_org_id')], + "verbose_name": "Email Message", + "verbose_name_plural": "Email Messages", + "db_table": "email_message", + "ordering": ("-received_at",), + "indexes": [ + models.Index( + fields=["org", "-received_at"], + name="email_messa_org_id_631bc3_idx", + ), + models.Index( + fields=["case", "-received_at"], + name="email_messa_case_id_bc66f7_idx", + ), + models.Index( + fields=["in_reply_to"], name="email_messa_in_repl_216d84_idx" + ), + ], + "constraints": [ + models.UniqueConstraint( + fields=("org", "message_id"), + name="uniq_email_message_per_org_id", + ) + ], }, ), migrations.CreateModel( - name='InboundMailbox', + name="InboundMailbox", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('address', models.EmailField(max_length=254, verbose_name='Inbound Address')), - ('provider', models.CharField(choices=[('ses', 'AWS SES (SNS direct delivery)'), ('mailgun', 'Mailgun'), ('postmark', 'Postmark'), ('imap', 'IMAP')], default='ses', max_length=16)), - ('webhook_secret', models.CharField(blank=True, default='', help_text='Shared secret / SNS Topic ARN suffix used to verify webhook calls. Auto-generated on create if left blank.', max_length=128)), - ('imap_host', models.CharField(blank=True, default='', max_length=255)), - ('imap_port', models.PositiveIntegerField(blank=True, null=True)), - ('imap_username', models.CharField(blank=True, default='', max_length=255)), - ('imap_password_enc', models.CharField(blank=True, default='', max_length=512)), - ('default_priority', models.CharField(choices=[('Low', 'Low'), ('Normal', 'Normal'), ('High', 'High'), ('Urgent', 'Urgent')], default='Normal', max_length=64)), - ('default_case_type', models.CharField(blank=True, choices=[('Question', 'Question'), ('Incident', 'Incident'), ('Problem', 'Problem')], max_length=255, null=True)), - ('is_active', models.BooleanField(default=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('default_assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_inbound_mailboxes', to='common.profile')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inbound_mailboxes', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "address", + models.EmailField(max_length=254, verbose_name="Inbound Address"), + ), + ( + "provider", + models.CharField( + choices=[ + ("ses", "AWS SES (SNS direct delivery)"), + ("mailgun", "Mailgun"), + ("postmark", "Postmark"), + ("imap", "IMAP"), + ], + default="ses", + max_length=16, + ), + ), + ( + "webhook_secret", + models.CharField( + blank=True, + default="", + help_text="Shared secret / SNS Topic ARN suffix used to verify webhook calls. Auto-generated on create if left blank.", + max_length=128, + ), + ), + ("imap_host", models.CharField(blank=True, default="", max_length=255)), + ("imap_port", models.PositiveIntegerField(blank=True, null=True)), + ( + "imap_username", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "imap_password_enc", + models.CharField(blank=True, default="", max_length=512), + ), + ( + "default_priority", + models.CharField( + choices=[ + ("Low", "Low"), + ("Normal", "Normal"), + ("High", "High"), + ("Urgent", "Urgent"), + ], + default="Normal", + max_length=64, + ), + ), + ( + "default_case_type", + models.CharField( + blank=True, + choices=[ + ("Question", "Question"), + ("Incident", "Incident"), + ("Problem", "Problem"), + ], + max_length=255, + null=True, + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "default_assignee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="default_inbound_mailboxes", + to="common.profile", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="inbound_mailboxes", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Inbound Mailbox', - 'verbose_name_plural': 'Inbound Mailboxes', - 'db_table': 'inbound_mailbox', - 'ordering': ('address',), - 'indexes': [models.Index(fields=['org', 'is_active'], name='inbound_mai_org_id_0a00ff_idx')], - 'constraints': [models.UniqueConstraint(fields=('org', 'address'), name='uniq_inbound_mailbox_per_org_address')], + "verbose_name": "Inbound Mailbox", + "verbose_name_plural": "Inbound Mailboxes", + "db_table": "inbound_mailbox", + "ordering": ("address",), + "indexes": [ + models.Index( + fields=["org", "is_active"], + name="inbound_mai_org_id_0a00ff_idx", + ) + ], + "constraints": [ + models.UniqueConstraint( + fields=("org", "address"), + name="uniq_inbound_mailbox_per_org_address", + ) + ], }, ), migrations.RunPython(enable_rls, reverse_code=disable_rls), diff --git a/backend/cases/migrations/0014_routing.py b/backend/cases/migrations/0014_routing.py index ee41213b..9a049a50 100644 --- a/backend/cases/migrations/0014_routing.py +++ b/backend/cases/migrations/0014_routing.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-09 12:31 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -25,63 +26,207 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('cases', '0013_inbound_email'), - ('common', '0017_customfielddefinition'), + ("cases", "0013_inbound_email"), + ("common", "0017_customfielddefinition"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='RoutingRule', + name="RoutingRule", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255)), - ('priority_order', models.PositiveIntegerField(db_index=True, default=100, help_text='Lower runs first; ties broken by created_at.')), - ('is_active', models.BooleanField(default=True)), - ('conditions', models.JSONField(blank=True, default=list, help_text='List of {field, op, value} triples; AND across entries.')), - ('strategy', models.CharField(choices=[('round_robin', 'Round-robin within target_assignees'), ('least_busy', 'Least busy in target_assignees'), ('direct', 'Single profile in target_assignees'), ('by_team', 'Assign to target_team')], default='direct', max_length=16)), - ('stop_processing', models.BooleanField(default=True, help_text='If a rule matches and this is True, skip lower-priority rules.')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='routing_rules', to='common.org')), - ('target_assignees', models.ManyToManyField(blank=True, related_name='routing_rules', to='common.profile')), - ('target_team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='routing_rules', to='common.teams')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "priority_order", + models.PositiveIntegerField( + db_index=True, + default=100, + help_text="Lower runs first; ties broken by created_at.", + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "conditions", + models.JSONField( + blank=True, + default=list, + help_text="List of {field, op, value} triples; AND across entries.", + ), + ), + ( + "strategy", + models.CharField( + choices=[ + ("round_robin", "Round-robin within target_assignees"), + ("least_busy", "Least busy in target_assignees"), + ("direct", "Single profile in target_assignees"), + ("by_team", "Assign to target_team"), + ], + default="direct", + max_length=16, + ), + ), + ( + "stop_processing", + models.BooleanField( + default=True, + help_text="If a rule matches and this is True, skip lower-priority rules.", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="routing_rules", + to="common.org", + ), + ), + ( + "target_assignees", + models.ManyToManyField( + blank=True, related_name="routing_rules", to="common.profile" + ), + ), + ( + "target_team", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="routing_rules", + to="common.teams", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Routing Rule', - 'verbose_name_plural': 'Routing Rules', - 'db_table': 'routing_rule', - 'ordering': ('priority_order', 'created_at'), + "verbose_name": "Routing Rule", + "verbose_name_plural": "Routing Rules", + "db_table": "routing_rule", + "ordering": ("priority_order", "created_at"), }, ), migrations.CreateModel( - name='RoutingRuleState', + name="RoutingRuleState", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('last_assigned_index', models.PositiveIntegerField(default=0)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='routing_rule_states', to='common.org')), - ('rule', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='state', to='cases.routingrule')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("last_assigned_index", models.PositiveIntegerField(default=0)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="routing_rule_states", + to="common.org", + ), + ), + ( + "rule", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="state", + to="cases.routingrule", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Routing Rule State', - 'verbose_name_plural': 'Routing Rule States', - 'db_table': 'routing_rule_state', + "verbose_name": "Routing Rule State", + "verbose_name_plural": "Routing Rule States", + "db_table": "routing_rule_state", }, ), migrations.AddIndex( - model_name='routingrule', - index=models.Index(fields=['org', 'is_active', 'priority_order'], name='rr_eval_idx'), + model_name="routingrule", + index=models.Index( + fields=["org", "is_active", "priority_order"], name="rr_eval_idx" + ), ), migrations.RunPython(enable_rls, reverse_code=disable_rls), ] diff --git a/backend/cases/migrations/0015_merge.py b/backend/cases/migrations/0015_merge.py index 680d160a..9553d821 100644 --- a/backend/cases/migrations/0015_merge.py +++ b/backend/cases/migrations/0015_merge.py @@ -5,31 +5,42 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('cases', '0014_routing'), - ('common', '0017_customfielddefinition'), + ("cases", "0014_routing"), + ("common", "0017_customfielddefinition"), ] operations = [ migrations.AddField( - model_name='case', - name='alt_thread_ids', + model_name="case", + name="alt_thread_ids", field=models.JSONField(blank=True, default=list), ), migrations.AddField( - model_name='case', - name='merged_at', + model_name="case", + name="merged_at", field=models.DateTimeField(blank=True, db_index=True, null=True), ), migrations.AddField( - model_name='case', - name='merged_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='merged_cases', to='common.profile'), + model_name="case", + name="merged_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="merged_cases", + to="common.profile", + ), ), migrations.AddField( - model_name='case', - name='merged_into', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='merged_from_cases', to='cases.case'), + model_name="case", + name="merged_into", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="merged_from_cases", + to="cases.case", + ), ), ] diff --git a/backend/cases/migrations/0016_watchers.py b/backend/cases/migrations/0016_watchers.py index fbb2fc65..57fddb32 100644 --- a/backend/cases/migrations/0016_watchers.py +++ b/backend/cases/migrations/0016_watchers.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-09 20:40 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -23,53 +24,132 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('cases', '0015_merge'), - ('common', '0018_add_notification'), + ("cases", "0015_merge"), + ("common", "0018_add_notification"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='CaseWatcher', + name="CaseWatcher", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('subscribed_via', models.CharField(choices=[('manual', 'Manual'), ('mention', 'Mention'), ('auto_assignee', 'Auto (Assignee)'), ('auto_team', 'Auto (Team)')], default='manual', max_length=16)), - ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='watcher_rows', to='cases.case')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='case_watchers', to='common.org')), - ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='watched_cases', to='common.profile')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "subscribed_via", + models.CharField( + choices=[ + ("manual", "Manual"), + ("mention", "Mention"), + ("auto_assignee", "Auto (Assignee)"), + ("auto_team", "Auto (Team)"), + ], + default="manual", + max_length=16, + ), + ), + ( + "case", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="watcher_rows", + to="cases.case", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="case_watchers", + to="common.org", + ), + ), + ( + "profile", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="watched_cases", + to="common.profile", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Case Watcher', - 'verbose_name_plural': 'Case Watchers', - 'db_table': 'case_watcher', - 'ordering': ('-created_at',), + "verbose_name": "Case Watcher", + "verbose_name_plural": "Case Watchers", + "db_table": "case_watcher", + "ordering": ("-created_at",), }, ), migrations.AddField( - model_name='case', - name='watchers', - field=models.ManyToManyField(blank=True, related_name='case_watching', through='cases.CaseWatcher', to='common.profile'), + model_name="case", + name="watchers", + field=models.ManyToManyField( + blank=True, + related_name="case_watching", + through="cases.CaseWatcher", + to="common.profile", + ), ), migrations.AddIndex( - model_name='casewatcher', - index=models.Index(fields=['profile', '-created_at'], name='case_watche_profile_88025c_idx'), + model_name="casewatcher", + index=models.Index( + fields=["profile", "-created_at"], name="case_watche_profile_88025c_idx" + ), ), migrations.AddIndex( - model_name='casewatcher', - index=models.Index(fields=['case'], name='case_watche_case_id_09be35_idx'), + model_name="casewatcher", + index=models.Index(fields=["case"], name="case_watche_case_id_09be35_idx"), ), migrations.AddConstraint( - model_name='casewatcher', - constraint=models.UniqueConstraint(fields=('case', 'profile'), name='uniq_case_watcher_per_profile'), + model_name="casewatcher", + constraint=models.UniqueConstraint( + fields=("case", "profile"), name="uniq_case_watcher_per_profile" + ), ), migrations.RunPython(enable_rls, reverse_code=disable_rls), ] diff --git a/backend/cases/migrations/0017_sla_pause.py b/backend/cases/migrations/0017_sla_pause.py index 915ee092..e752b369 100644 --- a/backend/cases/migrations/0017_sla_pause.py +++ b/backend/cases/migrations/0017_sla_pause.py @@ -4,20 +4,19 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('cases', '0016_watchers'), + ("cases", "0016_watchers"), ] operations = [ migrations.AddField( - model_name='case', - name='sla_paused_at', + model_name="case", + name="sla_paused_at", field=models.DateTimeField(blank=True, null=True), ), migrations.AddField( - model_name='case', - name='sla_paused_seconds', + model_name="case", + name="sla_paused_seconds", field=models.PositiveIntegerField(default=0), ), ] diff --git a/backend/cases/migrations/0018_case_resolved_idx.py b/backend/cases/migrations/0018_case_resolved_idx.py index a63b3f90..9c446069 100644 --- a/backend/cases/migrations/0018_case_resolved_idx.py +++ b/backend/cases/migrations/0018_case_resolved_idx.py @@ -6,7 +6,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0017_sla_pause"), ] diff --git a/backend/cases/migrations/0019_csatsurvey.py b/backend/cases/migrations/0019_csatsurvey.py index de41bb50..72b555ae 100644 --- a/backend/cases/migrations/0019_csatsurvey.py +++ b/backend/cases/migrations/0019_csatsurvey.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-09 23:08 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -23,42 +24,113 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('cases', '0018_case_resolved_idx'), - ('common', '0020_org_csat_enabled'), - ('contacts', '0011_inbound_email'), + ("cases", "0018_case_resolved_idx"), + ("common", "0020_org_csat_enabled"), + ("contacts", "0011_inbound_email"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='CsatSurvey', + name="CsatSurvey", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('token_hash', models.CharField(max_length=64, unique=True)), - ('sent_at', models.DateTimeField()), - ('rating', models.PositiveSmallIntegerField(blank=True, null=True)), - ('comment', models.TextField(blank=True, default='')), - ('responded_at', models.DateTimeField(blank=True, null=True)), - ('expires_at', models.DateTimeField()), - ('case', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='csat_survey', to='cases.case')), - ('contact', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='csat_surveys', to='contacts.contact')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='csat_surveys', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("token_hash", models.CharField(max_length=64, unique=True)), + ("sent_at", models.DateTimeField()), + ("rating", models.PositiveSmallIntegerField(blank=True, null=True)), + ("comment", models.TextField(blank=True, default="")), + ("responded_at", models.DateTimeField(blank=True, null=True)), + ("expires_at", models.DateTimeField()), + ( + "case", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="csat_survey", + to="cases.case", + ), + ), + ( + "contact", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="csat_surveys", + to="contacts.contact", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="csat_surveys", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'CSAT Survey', - 'verbose_name_plural': 'CSAT Surveys', - 'db_table': 'csat_survey', - 'ordering': ('-created_at',), - 'indexes': [models.Index(fields=['org', '-responded_at'], name='csat_survey_org_id_a284b2_idx'), models.Index(fields=['case'], name='csat_survey_case_id_516193_idx'), models.Index(fields=['token_hash'], name='csat_survey_token_h_2f45f7_idx')], + "verbose_name": "CSAT Survey", + "verbose_name_plural": "CSAT Surveys", + "db_table": "csat_survey", + "ordering": ("-created_at",), + "indexes": [ + models.Index( + fields=["org", "-responded_at"], + name="csat_survey_org_id_a284b2_idx", + ), + models.Index( + fields=["case"], name="csat_survey_case_id_516193_idx" + ), + models.Index( + fields=["token_hash"], name="csat_survey_token_h_2f45f7_idx" + ), + ], }, ), migrations.RunPython(enable_rls, reverse_code=disable_rls), diff --git a/backend/cases/migrations/0020_case_parent_is_problem.py b/backend/cases/migrations/0020_case_parent_is_problem.py index 8959ae14..297c288c 100644 --- a/backend/cases/migrations/0020_case_parent_is_problem.py +++ b/backend/cases/migrations/0020_case_parent_is_problem.py @@ -2,7 +2,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0019_csatsurvey"), ] diff --git a/backend/cases/migrations/0021_timeentry.py b/backend/cases/migrations/0021_timeentry.py index 6bca34e0..810af9a0 100644 --- a/backend/cases/migrations/0021_timeentry.py +++ b/backend/cases/migrations/0021_timeentry.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-10 00:05 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -23,46 +24,190 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('cases', '0020_case_parent_is_problem'), - ('common', '0021_org_auto_close_children'), - ('invoices', '0007_add_cancelled_at_field'), + ("cases", "0020_case_parent_is_problem"), + ("common", "0021_org_auto_close_children"), + ("invoices", "0007_add_cancelled_at_field"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='TimeEntry', + name="TimeEntry", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('started_at', models.DateTimeField()), - ('ended_at', models.DateTimeField(blank=True, help_text='Null while the timer is running.', null=True)), - ('duration_minutes', models.PositiveIntegerField(default=0, help_text='Recomputed on save when both timestamps are present.')), - ('description', models.TextField(blank=True, default='')), - ('billable', models.BooleanField(default=False)), - ('hourly_rate', models.DecimalField(blank=True, decimal_places=2, help_text='Snapshot at log time so future rate changes do not alter history.', max_digits=10, null=True)), - ('currency', models.CharField(choices=[('USD', 'USD, Dollar'), ('EUR', 'EUR, Euro'), ('GBP', 'GBP, Pound'), ('INR', 'INR, Rupee'), ('CAD', 'CAD, Dollar'), ('AUD', 'AUD, Dollar'), ('JPY', 'JPY, Yen'), ('CNY', 'CNY, Yuan'), ('CHF', 'CHF, Franc'), ('SGD', 'SGD, Dollar'), ('AED', 'AED, Dirham'), ('BRL', 'BRL, Real'), ('MXN', 'MXN, Peso')], default='USD', max_length=3)), - ('auto_stopped', models.BooleanField(default=False, help_text='Set by the auto-stop Celery task when a forgotten timer is killed.')), - ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to='cases.case')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('invoice', models.ForeignKey(blank=True, help_text='Set when this entry has been pushed to a draft invoice.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='time_entries', to='invoices.invoice')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to='common.org')), - ('profile', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='time_entries', to='common.profile')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("started_at", models.DateTimeField()), + ( + "ended_at", + models.DateTimeField( + blank=True, + help_text="Null while the timer is running.", + null=True, + ), + ), + ( + "duration_minutes", + models.PositiveIntegerField( + default=0, + help_text="Recomputed on save when both timestamps are present.", + ), + ), + ("description", models.TextField(blank=True, default="")), + ("billable", models.BooleanField(default=False)), + ( + "hourly_rate", + models.DecimalField( + blank=True, + decimal_places=2, + help_text="Snapshot at log time so future rate changes do not alter history.", + max_digits=10, + null=True, + ), + ), + ( + "currency", + models.CharField( + choices=[ + ("USD", "USD, Dollar"), + ("EUR", "EUR, Euro"), + ("GBP", "GBP, Pound"), + ("INR", "INR, Rupee"), + ("CAD", "CAD, Dollar"), + ("AUD", "AUD, Dollar"), + ("JPY", "JPY, Yen"), + ("CNY", "CNY, Yuan"), + ("CHF", "CHF, Franc"), + ("SGD", "SGD, Dollar"), + ("AED", "AED, Dirham"), + ("BRL", "BRL, Real"), + ("MXN", "MXN, Peso"), + ], + default="USD", + max_length=3, + ), + ), + ( + "auto_stopped", + models.BooleanField( + default=False, + help_text="Set by the auto-stop Celery task when a forgotten timer is killed.", + ), + ), + ( + "case", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="time_entries", + to="cases.case", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "invoice", + models.ForeignKey( + blank=True, + help_text="Set when this entry has been pushed to a draft invoice.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="time_entries", + to="invoices.invoice", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="time_entries", + to="common.org", + ), + ), + ( + "profile", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="time_entries", + to="common.profile", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Time Entry', - 'verbose_name_plural': 'Time Entries', - 'db_table': 'time_entry', - 'ordering': ('-started_at',), - 'indexes': [models.Index(fields=['org', 'profile', 'started_at'], name='time_entry_org_id_93126d_idx'), models.Index(fields=['case', 'started_at'], name='time_entry_case_id_b266ff_idx'), models.Index(fields=['billable', 'invoice'], name='time_entry_billabl_2174a0_idx')], - 'constraints': [models.CheckConstraint(condition=models.Q(('ended_at__isnull', True), ('ended_at__gte', models.F('started_at')), _connector='OR'), name='time_entry_end_after_start'), models.UniqueConstraint(condition=models.Q(('ended_at__isnull', True)), fields=('profile',), name='one_active_timer_per_profile')], + "verbose_name": "Time Entry", + "verbose_name_plural": "Time Entries", + "db_table": "time_entry", + "ordering": ("-started_at",), + "indexes": [ + models.Index( + fields=["org", "profile", "started_at"], + name="time_entry_org_id_93126d_idx", + ), + models.Index( + fields=["case", "started_at"], + name="time_entry_case_id_b266ff_idx", + ), + models.Index( + fields=["billable", "invoice"], + name="time_entry_billabl_2174a0_idx", + ), + ], + "constraints": [ + models.CheckConstraint( + condition=models.Q( + ("ended_at__isnull", True), + ("ended_at__gte", models.F("started_at")), + _connector="OR", + ), + name="time_entry_end_after_start", + ), + models.UniqueConstraint( + condition=models.Q(("ended_at__isnull", True)), + fields=("profile",), + name="one_active_timer_per_profile", + ), + ], }, ), migrations.RunPython(enable_rls, reverse_code=disable_rls), diff --git a/backend/cases/migrations/0022_approvalrule_approval_and_more.py b/backend/cases/migrations/0022_approvalrule_approval_and_more.py index 7e53e904..059d4d11 100644 --- a/backend/cases/migrations/0022_approvalrule_approval_and_more.py +++ b/backend/cases/migrations/0022_approvalrule_approval_and_more.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-10 00:37 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -25,82 +26,273 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('cases', '0021_timeentry'), - ('common', '0021_org_auto_close_children'), + ("cases", "0021_timeentry"), + ("common", "0021_org_auto_close_children"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='ApprovalRule', + name="ApprovalRule", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=128)), - ('trigger_event', models.CharField(choices=[('pre_close', 'Pre-Close')], default='pre_close', max_length=16)), - ('match_priority', models.CharField(blank=True, choices=[('Low', 'Low'), ('Normal', 'Normal'), ('High', 'High'), ('Urgent', 'Urgent')], max_length=32, null=True)), - ('match_case_type', models.CharField(blank=True, choices=[('Question', 'Question'), ('Incident', 'Incident'), ('Problem', 'Problem')], max_length=32, null=True)), - ('approver_role', models.CharField(choices=[('ADMIN', 'Admin'), ('MANAGER', 'Manager')], default='ADMIN', max_length=16)), - ('is_active', models.BooleanField(default=True)), - ('approvers', models.ManyToManyField(blank=True, related_name='approval_rules', to='common.profile')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('match_team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approval_rules', to='common.teams')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approval_rules', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=128)), + ( + "trigger_event", + models.CharField( + choices=[("pre_close", "Pre-Close")], + default="pre_close", + max_length=16, + ), + ), + ( + "match_priority", + models.CharField( + blank=True, + choices=[ + ("Low", "Low"), + ("Normal", "Normal"), + ("High", "High"), + ("Urgent", "Urgent"), + ], + max_length=32, + null=True, + ), + ), + ( + "match_case_type", + models.CharField( + blank=True, + choices=[ + ("Question", "Question"), + ("Incident", "Incident"), + ("Problem", "Problem"), + ], + max_length=32, + null=True, + ), + ), + ( + "approver_role", + models.CharField( + choices=[("ADMIN", "Admin"), ("MANAGER", "Manager")], + default="ADMIN", + max_length=16, + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "approvers", + models.ManyToManyField( + blank=True, related_name="approval_rules", to="common.profile" + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "match_team", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="approval_rules", + to="common.teams", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="approval_rules", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Approval Rule', - 'verbose_name_plural': 'Approval Rules', - 'db_table': 'approval_rule', - 'ordering': ('-created_at',), + "verbose_name": "Approval Rule", + "verbose_name_plural": "Approval Rules", + "db_table": "approval_rule", + "ordering": ("-created_at",), }, ), migrations.CreateModel( - name='Approval', + name="Approval", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('state', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('cancelled', 'Cancelled')], default='pending', max_length=16)), - ('note', models.TextField(blank=True, default='')), - ('reason', models.TextField(blank=True, default='')), - ('decided_at', models.DateTimeField(blank=True, null=True)), - ('approver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='approvals_assigned', to='common.profile')), - ('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='cases.case')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='common.org')), - ('requested_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='approvals_requested', to='common.profile')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), - ('rule', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='requests', to='cases.approvalrule')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "state", + models.CharField( + choices=[ + ("pending", "Pending"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ("cancelled", "Cancelled"), + ], + default="pending", + max_length=16, + ), + ), + ("note", models.TextField(blank=True, default="")), + ("reason", models.TextField(blank=True, default="")), + ("decided_at", models.DateTimeField(blank=True, null=True)), + ( + "approver", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="approvals_assigned", + to="common.profile", + ), + ), + ( + "case", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="approvals", + to="cases.case", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="approvals", + to="common.org", + ), + ), + ( + "requested_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="approvals_requested", + to="common.profile", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "rule", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="requests", + to="cases.approvalrule", + ), + ), ], options={ - 'verbose_name': 'Approval', - 'verbose_name_plural': 'Approvals', - 'db_table': 'approval', - 'ordering': ('-created_at',), + "verbose_name": "Approval", + "verbose_name_plural": "Approvals", + "db_table": "approval", + "ordering": ("-created_at",), }, ), migrations.AddIndex( - model_name='approvalrule', - index=models.Index(fields=['org', 'trigger_event', 'is_active'], name='approval_rule_match_idx'), + model_name="approvalrule", + index=models.Index( + fields=["org", "trigger_event", "is_active"], + name="approval_rule_match_idx", + ), ), migrations.AddIndex( - model_name='approval', - index=models.Index(fields=['org', 'state'], name='approval_org_state_idx'), + model_name="approval", + index=models.Index(fields=["org", "state"], name="approval_org_state_idx"), ), migrations.AddIndex( - model_name='approval', - index=models.Index(fields=['case', 'state'], name='approval_case_state_idx'), + model_name="approval", + index=models.Index( + fields=["case", "state"], name="approval_case_state_idx" + ), ), migrations.AddIndex( - model_name='approval', - index=models.Index(fields=['approver', 'state'], name='approval_approver_idx'), + model_name="approval", + index=models.Index( + fields=["approver", "state"], name="approval_approver_idx" + ), ), migrations.RunPython(enable_rls, reverse_code=disable_rls), ] diff --git a/backend/cases/migrations/0023_case_merge_record.py b/backend/cases/migrations/0023_case_merge_record.py index 4039eed1..644f1751 100644 --- a/backend/cases/migrations/0023_case_merge_record.py +++ b/backend/cases/migrations/0023_case_merge_record.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('cases', '0022_approvalrule_approval_and_more'), + ("cases", "0022_approvalrule_approval_and_more"), ] operations = [ migrations.AddField( - model_name='case', - name='merge_record', + model_name="case", + name="merge_record", field=models.JSONField(blank=True, null=True), ), ] diff --git a/backend/cases/migrations/0024_emailmessage_mailbox.py b/backend/cases/migrations/0024_emailmessage_mailbox.py index e58f94bc..89195f53 100644 --- a/backend/cases/migrations/0024_emailmessage_mailbox.py +++ b/backend/cases/migrations/0024_emailmessage_mailbox.py @@ -24,7 +24,6 @@ def backfill_mailbox(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("cases", "0023_case_merge_record"), ] diff --git a/backend/cases/migrations/0025_inboundmailbox_topic_arn_and_more.py b/backend/cases/migrations/0025_inboundmailbox_topic_arn_and_more.py index 6a84ea6a..6415db3a 100644 --- a/backend/cases/migrations/0025_inboundmailbox_topic_arn_and_more.py +++ b/backend/cases/migrations/0025_inboundmailbox_topic_arn_and_more.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0024_emailmessage_mailbox"), ] diff --git a/backend/cases/migrations/0026_case_is_sample.py b/backend/cases/migrations/0026_case_is_sample.py index e16e8cc4..966ecead 100644 --- a/backend/cases/migrations/0026_case_is_sample.py +++ b/backend/cases/migrations/0026_case_is_sample.py @@ -4,15 +4,17 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('cases', '0025_inboundmailbox_topic_arn_and_more'), + ("cases", "0025_inboundmailbox_topic_arn_and_more"), ] operations = [ migrations.AddField( - model_name='case', - name='is_sample', - field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + model_name="case", + name="is_sample", + field=models.BooleanField( + default=False, + help_text="True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.", + ), ), ] diff --git a/backend/cases/migrations/0027_alter_casepipeline_is_default.py b/backend/cases/migrations/0027_alter_casepipeline_is_default.py index 8efda0b8..8256dba6 100644 --- a/backend/cases/migrations/0027_alter_casepipeline_is_default.py +++ b/backend/cases/migrations/0027_alter_casepipeline_is_default.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("cases", "0026_case_is_sample"), ] diff --git a/backend/cases/migrations/0028_inboundmailbox_webhook_secret_help_text.py b/backend/cases/migrations/0028_inboundmailbox_webhook_secret_help_text.py index 188b36df..ad7107a4 100644 --- a/backend/cases/migrations/0028_inboundmailbox_webhook_secret_help_text.py +++ b/backend/cases/migrations/0028_inboundmailbox_webhook_secret_help_text.py @@ -4,15 +4,19 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('cases', '0027_alter_casepipeline_is_default'), + ("cases", "0027_alter_casepipeline_is_default"), ] operations = [ migrations.AlterField( - model_name='inboundmailbox', - name='webhook_secret', - field=models.CharField(blank=True, default='', help_text='Reserved for providers that sign deliveries with a shared secret, none of which are implemented yet. Nothing compares this value today: the SES webhook is authenticated by the SNS signature and the topic_arn pin below. Admin-writable so a provider-issued key can be stored, and write-only, so it is never returned by the API.', max_length=128), + model_name="inboundmailbox", + name="webhook_secret", + field=models.CharField( + blank=True, + default="", + help_text="Reserved for providers that sign deliveries with a shared secret, none of which are implemented yet. Nothing compares this value today: the SES webhook is authenticated by the SNS signature and the topic_arn pin below. Admin-writable so a provider-issued key can be stored, and write-only, so it is never returned by the API.", + max_length=128, + ), ), ] diff --git a/backend/cases/notifications.py b/backend/cases/notifications.py index 0634a335..02b57a82 100644 --- a/backend/cases/notifications.py +++ b/backend/cases/notifications.py @@ -163,9 +163,7 @@ def dispatch_for_comment(comment, case, actor: Profile | None = None) -> dict: if actor_id is not None: skip_ids.add(actor_id) watcher_profiles = ( - Profile.objects.filter( - case_watching=case, is_active=True - ) + Profile.objects.filter(case_watching=case, is_active=True) .exclude(id__in=skip_ids) .distinct() ) diff --git a/backend/cases/parent_views.py b/backend/cases/parent_views.py index f4131886..67241825 100644 --- a/backend/cases/parent_views.py +++ b/backend/cases/parent_views.py @@ -12,7 +12,6 @@ build a cycle. Cascade close honours ``Org.auto_close_children_on_parent_close`` as the default, and accepts ``cascade`` in the body to override. """ - from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -26,7 +25,6 @@ from cases.parent_guards import check_parent_link from common.models import Activity from common.permissions import HasOrgContext - TREE_MAX_DEPTH = Case.PARENT_MAX_DEPTH @@ -99,15 +97,9 @@ class CaseLinkParentView(APIView): parent_id = request.data.get("parent_id") # Lock the case row so a parallel link from another agent cannot # race on the cycle check. We look up the parent under the same lock. - case = ( - Case.objects.select_for_update() - .filter(id=pk, org=org) - .first() - ) + case = Case.objects.select_for_update().filter(id=pk, org=org).first() if case is None: - return Response( - {"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) former_parent_id = case.parent_id @@ -132,11 +124,7 @@ class CaseLinkParentView(APIView): status=status.HTTP_200_OK, ) - parent = ( - Case.objects.select_for_update() - .filter(id=parent_id, org=org) - .first() - ) + parent = Case.objects.select_for_update().filter(id=parent_id, org=org).first() if parent is None: return Response( {"parent_id": "Parent case not found in this organization."}, @@ -198,15 +186,9 @@ class CaseCloseWithChildrenView(APIView): @transaction.atomic def post(self, request, pk): org = request.profile.org - case = ( - Case.objects.select_for_update() - .filter(id=pk, org=org) - .first() - ) + case = Case.objects.select_for_update().filter(id=pk, org=org).first() if case is None: - return Response( - {"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) if case.status == "Duplicate": return Response( {"detail": "Cannot close a merged case."}, diff --git a/backend/cases/routing.py b/backend/cases/routing.py index b9deb794..fe1243a9 100644 --- a/backend/cases/routing.py +++ b/backend/cases/routing.py @@ -144,9 +144,7 @@ def _matches_all(case_data: dict, conditions: list[dict]) -> bool: def _active_pool(rule: RoutingRule) -> list[Profile]: - return list( - rule.target_assignees.filter(is_active=True).order_by("id") - ) + return list(rule.target_assignees.filter(is_active=True).order_by("id")) def _least_busy(profiles: Iterable[Profile]) -> Profile | None: @@ -169,7 +167,9 @@ def _least_busy(profiles: Iterable[Profile]) -> Profile | None: return profiles[0] -def _round_robin(rule: RoutingRule, pool: list[Profile], dry_run: bool) -> Profile | None: +def _round_robin( + rule: RoutingRule, pool: list[Profile], dry_run: bool +) -> Profile | None: if not pool: return None if dry_run: @@ -179,11 +179,7 @@ def _round_robin(rule: RoutingRule, pool: list[Profile], dry_run: bool) -> Profi return pool[idx] with transaction.atomic(): - state = ( - RoutingRuleState.objects.select_for_update() - .filter(rule=rule) - .first() - ) + state = RoutingRuleState.objects.select_for_update().filter(rule=rule).first() if state is None: state = RoutingRuleState.objects.create( rule=rule, org=rule.org, last_assigned_index=0 @@ -231,7 +227,9 @@ def _case_to_dict(case) -> dict: } -def evaluate(case, *, dry_run: bool = False, case_data: dict | None = None) -> RoutingDecision: +def evaluate( + case, *, dry_run: bool = False, case_data: dict | None = None +) -> RoutingDecision: """Evaluate active routing rules for `case.org` against the case. Walks rules in `priority_order` ASC. The first matching rule executes its @@ -299,7 +297,9 @@ def evaluate(case, *, dry_run: bool = False, case_data: dict | None = None) -> R return decision -def _apply(case, rule: RoutingRule, chosen_profile: Profile | None, decision: RoutingDecision): +def _apply( + case, rule: RoutingRule, chosen_profile: Profile | None, decision: RoutingDecision +): """Persist assignment + write an Activity(ROUTED) row. No-ops if the rule matched but produced no target (empty pool / missing diff --git a/backend/cases/serializer.py b/backend/cases/serializer.py index 63badb22..af3b86a0 100644 --- a/backend/cases/serializer.py +++ b/backend/cases/serializer.py @@ -729,9 +729,9 @@ class RoutingRuleSerializer(serializers.ModelSerializer): if org is None: org = self.context.get("org") if hasattr(self, "context") else None if org is not None: - self.fields["target_assignee_ids"].child_relation.queryset = ( - Profile.objects.filter(org=org) - ) + self.fields[ + "target_assignee_ids" + ].child_relation.queryset = Profile.objects.filter(org=org) self.fields["target_team_id"].queryset = Teams.objects.filter(org=org) def validate_conditions(self, value): @@ -993,9 +993,9 @@ class ApprovalRuleSerializer(serializers.ModelSerializer): # approver_ids/match_team_id accept ANY org's rows, so a PUT (whose # view does not re-run the POST's manual org check) could attach a # foreign-tenant Profile or Team to the rule. - self.fields["approver_ids"].child_relation.queryset = ( - Profile.objects.filter(org=org) - ) + self.fields[ + "approver_ids" + ].child_relation.queryset = Profile.objects.filter(org=org) self.fields["match_team_id"].queryset = Teams.objects.filter(org=org) diff --git a/backend/cases/services/billing.py b/backend/cases/services/billing.py index 8b2d1286..6d12aed2 100644 --- a/backend/cases/services/billing.py +++ b/backend/cases/services/billing.py @@ -38,8 +38,7 @@ def build_invoice_lines_from_entries(entries) -> Tuple[str, List[dict]]: currencies = {e.currency for e in entries} if len(currencies) > 1: raise TimeEntryInvoicingError( - "Cannot invoice entries in multiple currencies: " - f"{sorted(currencies)}." + f"Cannot invoice entries in multiple currencies: {sorted(currencies)}." ) currency = next(iter(currencies)) diff --git a/backend/cases/services/csv_import.py b/backend/cases/services/csv_import.py index 2b42cc2b..db0c12b0 100644 --- a/backend/cases/services/csv_import.py +++ b/backend/cases/services/csv_import.py @@ -32,7 +32,6 @@ from common.models import Profile, Tags, Teams from common.utils import CASE_TYPE, PRIORITY_CHOICE, STATUS_CHOICE from contacts.models import Contact - REQUIRED_HEADERS = ("name", "status", "priority") OPTIONAL_HEADERS = ( "description", @@ -217,7 +216,10 @@ def parse_and_validate(file_bytes: bytes, org) -> ImportResult: for idx, raw_row in enumerate(data_rows, start=1): if not any((cell or "").strip() for cell in raw_row): continue - record = {h: (raw_row[i].strip() if i < len(raw_row) else "") for i, h in enumerate(headers)} + record = { + h: (raw_row[i].strip() if i < len(raw_row) else "") + for i, h in enumerate(headers) + } parsed.append((idx, record)) ref_maps = _build_ref_maps(parsed, org) @@ -339,9 +341,7 @@ def _validate_and_build( if not name: errors.append(RowError(idx, "name", "Name is required")) elif len(name) > NAME_MAX_LEN: - errors.append( - RowError(idx, "name", f"Name exceeds {NAME_MAX_LEN} characters") - ) + errors.append(RowError(idx, "name", f"Name exceeds {NAME_MAX_LEN} characters")) else: lowered = name.lower() prior = seen_names.get(lowered) @@ -357,9 +357,7 @@ def _validate_and_build( status = _coerce_choice(record.get("status", ""), STATUS_CHOICE) if not status: valid_values = ", ".join(v for v, _ in STATUS_CHOICE) - errors.append( - RowError(idx, "status", f"Status must be one of: {valid_values}") - ) + errors.append(RowError(idx, "status", f"Status must be one of: {valid_values}")) priority = _coerce_choice(record.get("priority", ""), PRIORITY_CHOICE) if not priority: @@ -380,9 +378,7 @@ def _validate_and_build( closed_on = record.get("closed_on", "") if closed_on and not DATE_RE.match(closed_on): - errors.append( - RowError(idx, "closed_on", "Date must be in YYYY-MM-DD format") - ) + errors.append(RowError(idx, "closed_on", "Date must be in YYYY-MM-DD format")) account_id: str | None = None account_name = record.get("account_name", "") @@ -431,9 +427,7 @@ def _validate_and_build( for team_name in _split_multi(record.get("team_names", "")): resolved = refs.teams.get(team_name.lower()) if resolved is None: - errors.append( - RowError(idx, "team_names", f"No team named '{team_name}'") - ) + errors.append(RowError(idx, "team_names", f"No team named '{team_name}'")) else: team_ids.append(resolved) @@ -501,7 +495,9 @@ def commit_rows(file_bytes: bytes, org, profile) -> dict[str, Any]: if vr.team_ids: case.teams.set(vr.team_ids) if vr.tag_names: - tag_objs = [_get_or_create_tag(name, org, tag_cache) for name in vr.tag_names] + tag_objs = [ + _get_or_create_tag(name, org, tag_cache) for name in vr.tag_names + ] case.tags.set(tag_objs) created_ids.append(str(case.id)) diff --git a/backend/cases/signals.py b/backend/cases/signals.py index 7269b78b..c3a0f060 100644 --- a/backend/cases/signals.py +++ b/backend/cases/signals.py @@ -18,7 +18,6 @@ import logging from crum import get_current_user from django.contrib.contenttypes.models import ContentType -from django.utils import timezone from django.db.models.signals import ( m2m_changed, post_delete, @@ -26,6 +25,7 @@ from django.db.models.signals import ( pre_save, ) from django.dispatch import receiver +from django.utils import timezone from cases.models import Case, ReopenPolicy, Solution, TimeEntry from common.models import Activity, Comment diff --git a/backend/cases/solution_views.py b/backend/cases/solution_views.py index c5ffa20d..59dc9cc0 100644 --- a/backend/cases/solution_views.py +++ b/backend/cases/solution_views.py @@ -121,7 +121,7 @@ class SolutionListView(APIView, LimitOffsetPagination): # Counted over the whole knowledge base, not over the filtered page. # These four are a partition of the KB, "12 total, 3 of them drafts" - #, so recomputing them inside a `?status=draft` filter would leave + # , so recomputing them inside a `?status=draft` filter would leave # the other three cards reading zero and say nothing. counts = base.aggregate( count=Count("id"), diff --git a/backend/cases/tasks.py b/backend/cases/tasks.py index f9fe61a2..053b0efe 100644 --- a/backend/cases/tasks.py +++ b/backend/cases/tasks.py @@ -316,7 +316,7 @@ def send_csat_survey(case_id, org_id): # Template missing in dev: fall back to a plain link so the task # still records the survey row + token. Production has the template. html = ( - f'

Hi {contact.first_name or "there"},

' + f"

Hi {contact.first_name or 'there'},

" f'

How did we do on case "{case.name}"?

' f'

Rate your experience

' ) diff --git a/backend/cases/tests/conftest.py b/backend/cases/tests/conftest.py index 95969295..08ca04da 100644 --- a/backend/cases/tests/conftest.py +++ b/backend/cases/tests/conftest.py @@ -14,9 +14,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) @pytest.fixture diff --git a/backend/cases/tests/test_analytics.py b/backend/cases/tests/test_analytics.py index 28b696cd..95972f99 100644 --- a/backend/cases/tests/test_analytics.py +++ b/backend/cases/tests/test_analytics.py @@ -5,7 +5,8 @@ depend on the request layer. Window math, percentiles, and breach counting are the things that historically slip; everything else is a thin DB query. """ -from datetime import datetime, timedelta, timezone as dt_timezone +from datetime import datetime, timedelta +from datetime import timezone as dt_timezone import pytest from django.utils import timezone @@ -168,10 +169,18 @@ class TestComputeMttr: def test_by_priority_split(self, org_a, cases_qs): c1 = Case.objects.create( - org=org_a, name="U1", status="Closed", priority="Urgent", closed_on="2026-05-01" + org=org_a, + name="U1", + status="Closed", + priority="Urgent", + closed_on="2026-05-01", ) c2 = Case.objects.create( - org=org_a, name="L1", status="Closed", priority="Low", closed_on="2026-05-02" + org=org_a, + name="L1", + status="Closed", + priority="Low", + closed_on="2026-05-02", ) Case.objects.filter(pk=c1.pk).update( created_at=_utc(2026, 5, 1, 0), resolved_at=_utc(2026, 5, 1, 4) @@ -188,7 +197,11 @@ class TestComputeMttr: def test_only_resolved_in_window_counted(self, org_a, cases_qs): # Closed before window: ignored. c = Case.objects.create( - org=org_a, name="Old", status="Closed", priority="Low", closed_on="2026-04-01" + org=org_a, + name="Old", + status="Closed", + priority="Low", + closed_on="2026-04-01", ) Case.objects.filter(pk=c.pk).update( created_at=_utc(2026, 4, 1, 0), resolved_at=_utc(2026, 4, 1, 4) @@ -230,11 +243,17 @@ class TestComputeBacklog: class TestComputeAgents: def test_groups_per_assignee(self, org_a, cases_qs, user_profile, admin_profile): c1 = Case.objects.create( - org=org_a, name="A", status="New", priority="Normal", + org=org_a, + name="A", + status="New", + priority="Normal", sla_first_response_hours=4, ) c2 = Case.objects.create( - org=org_a, name="B", status="New", priority="Normal", + org=org_a, + name="B", + status="New", + priority="Normal", sla_first_response_hours=4, ) c1.assigned_to.add(user_profile) @@ -256,9 +275,14 @@ class TestComputeAgents: # csat_avg always None until CsatSurvey ships. assert all(row["csat_avg"] is None for row in out) - def test_multi_assignee_counts_each(self, org_a, cases_qs, user_profile, admin_profile): + def test_multi_assignee_counts_each( + self, org_a, cases_qs, user_profile, admin_profile + ): c = Case.objects.create( - org=org_a, name="Shared", status="New", priority="Normal", + org=org_a, + name="Shared", + status="New", + priority="Normal", ) c.assigned_to.add(user_profile, admin_profile) Case.objects.filter(pk=c.pk).update(created_at=_utc(2026, 5, 1, 8)) @@ -280,12 +304,20 @@ class TestComputeSla: def test_breach_rates_split_by_priority(self, org_a, cases_qs): # One Urgent that breached FRT, one Normal that responded fine. c1 = Case.objects.create( - org=org_a, name="U", status="New", priority="Urgent", - sla_first_response_hours=1, sla_resolution_hours=4, + org=org_a, + name="U", + status="New", + priority="Urgent", + sla_first_response_hours=1, + sla_resolution_hours=4, ) c2 = Case.objects.create( - org=org_a, name="N", status="New", priority="Normal", - sla_first_response_hours=4, sla_resolution_hours=24, + org=org_a, + name="N", + status="New", + priority="Normal", + sla_first_response_hours=4, + sla_resolution_hours=24, ) Case.objects.filter(pk=c1.pk).update( created_at=_utc(2026, 5, 1, 0), diff --git a/backend/cases/tests/test_analytics_api.py b/backend/cases/tests/test_analytics_api.py index 2369ed5e..80386d85 100644 --- a/backend/cases/tests/test_analytics_api.py +++ b/backend/cases/tests/test_analytics_api.py @@ -7,7 +7,8 @@ and CSV streaming. from __future__ import annotations -from datetime import datetime, timezone as dt_timezone +from datetime import datetime +from datetime import timezone as dt_timezone import pytest @@ -22,12 +23,22 @@ def _utc(year, month, day, hour=0): def cases_in_window(org_a): """Two cases with first_response_at and resolved_at on 2026-05-01.""" c1 = Case.objects.create( - org=org_a, name="A", status="Closed", priority="Urgent", - sla_first_response_hours=1, sla_resolution_hours=2, closed_on="2026-05-01", + org=org_a, + name="A", + status="Closed", + priority="Urgent", + sla_first_response_hours=1, + sla_resolution_hours=2, + closed_on="2026-05-01", ) c2 = Case.objects.create( - org=org_a, name="B", status="Closed", priority="Normal", - sla_first_response_hours=4, sla_resolution_hours=24, closed_on="2026-05-01", + org=org_a, + name="B", + status="Closed", + priority="Normal", + sla_first_response_hours=4, + sla_resolution_hours=24, + closed_on="2026-05-01", ) Case.objects.filter(pk=c1.pk).update( created_at=_utc(2026, 5, 1, 8), @@ -79,7 +90,10 @@ class TestAnalyticsFiltering: def test_frt_default_window_excludes_old(self, admin_client, org_a): # Backdate a case far past the default 30-day window. c = Case.objects.create( - org=org_a, name="Ancient", status="New", priority="Normal", + org=org_a, + name="Ancient", + status="New", + priority="Normal", sla_first_response_hours=4, ) Case.objects.filter(pk=c.pk).update( @@ -141,9 +155,7 @@ class TestEndpointSmokes: class TestRlsIsolation: - def test_org_b_does_not_see_org_a_metrics( - self, org_b_client, cases_in_window - ): + def test_org_b_does_not_see_org_a_metrics(self, org_b_client, cases_in_window): resp = org_b_client.get( "/api/cases/analytics/frt/?from=2026-05-01&to=2026-05-02" ) @@ -199,7 +211,5 @@ class TestCsvExport: assert lines[0].split(",")[0] == "ID" def test_csv_format_validation(self, admin_client): - resp = admin_client.get( - "/api/cases/analytics/export/?metric=frt&fmt=xlsx" - ) + resp = admin_client.get("/api/cases/analytics/export/?metric=frt&fmt=xlsx") assert resp.status_code == 400 diff --git a/backend/cases/tests/test_approval_authz.py b/backend/cases/tests/test_approval_authz.py index feff4bd5..fe57d178 100644 --- a/backend/cases/tests/test_approval_authz.py +++ b/backend/cases/tests/test_approval_authz.py @@ -66,9 +66,7 @@ def outsider_profile(org_a, django_user_model): def outsider_client(outsider_profile, org_a): from conftest import _make_authenticated_client - return _make_authenticated_client( - outsider_profile.user, org_a, outsider_profile - ) + return _make_authenticated_client(outsider_profile.user, org_a, outsider_profile) @pytest.fixture @@ -168,9 +166,7 @@ class TestInboxStillShowsTheFourWhoMaySee: @pytest.mark.django_db class TestRequestingApprovalNeedsCaseAccess: - def test_an_unrelated_member_is_refused( - self, outsider_client, org_a, hidden_case - ): + def test_an_unrelated_member_is_refused(self, outsider_client, org_a, hidden_case): _rule(org_a, approver_role="ADMIN") response = outsider_client.post( f"/api/cases/{hidden_case.id}/request-approval/", {}, format="json" diff --git a/backend/cases/tests/test_approvals.py b/backend/cases/tests/test_approvals.py index 517f0e97..7e5bc881 100644 --- a/backend/cases/tests/test_approvals.py +++ b/backend/cases/tests/test_approvals.py @@ -7,16 +7,15 @@ request flow, the inbox endpoint, and the four state transitions from __future__ import annotations - import pytest from crum import impersonate from django.core.exceptions import ValidationError +from django.utils import timezone from cases.approvals import Approval, ApprovalRule, find_matching_rule from cases.models import Case from common.models import Activity from conftest import rls_org -from django.utils import timezone def _make_case( @@ -94,9 +93,9 @@ class TestRuleMatching: match_case_type="Incident", ) chosen = find_matching_rule(case) - assert ( - chosen == specific - ), f"More-specific rule should win, got: {chosen.name if chosen else None}" + assert chosen == specific, ( + f"More-specific rule should win, got: {chosen.name if chosen else None}" + ) # Unused but covers the broader rule still being a candidate. assert broad.matches(case) is True diff --git a/backend/cases/tests/test_audit_log.py b/backend/cases/tests/test_audit_log.py index bfa86245..1b839297 100644 --- a/backend/cases/tests/test_audit_log.py +++ b/backend/cases/tests/test_audit_log.py @@ -34,9 +34,9 @@ class TestCaseSignalActivities: assert response.status_code == 200 case_id = response.json()["id"] rows = list( - Activity.objects.filter( - entity_type="Case", entity_id=case_id - ).values_list("action", flat=True) + Activity.objects.filter(entity_type="Case", entity_id=case_id).values_list( + "action", flat=True + ) ) assert "CREATE" in rows @@ -103,9 +103,7 @@ class TestCaseSignalActivities: created_by=admin_user, ) case_a.solutions.add(sol) - rows = [ - r for r in _case_activities(case_a) if r.action == "LINKED_SOLUTION" - ] + rows = [r for r in _case_activities(case_a) if r.action == "LINKED_SOLUTION"] assert len(rows) == 1 assert rows[0].metadata == {"solution_id": str(sol.pk)} @@ -156,27 +154,19 @@ class TestActivityFeedAPI: {"status": status}, content_type="application/json", ) - response = admin_client.get( - f"/api/cases/{case_a.pk}/activities/?limit=2" - ) + response = admin_client.get(f"/api/cases/{case_a.pk}/activities/?limit=2") assert response.status_code == 200 body = response.json() assert "activities" in body assert len(body["activities"]) == 2 assert body["count"] >= 5 - def test_activities_endpoint_cross_org_isolated( - self, admin_client, case_b - ): + def test_activities_endpoint_cross_org_isolated(self, admin_client, case_b): response = admin_client.get(f"/api/cases/{case_b.pk}/activities/") assert response.status_code == 404 - def test_activities_endpoint_unauthenticated( - self, unauthenticated_client, case_a - ): - response = unauthenticated_client.get( - f"/api/cases/{case_a.pk}/activities/" - ) + def test_activities_endpoint_unauthenticated(self, unauthenticated_client, case_a): + response = unauthenticated_client.get(f"/api/cases/{case_a.pk}/activities/") # DRF without a WWW-Authenticate challenge returns 403 here, matching # every other Cases endpoint. assert response.status_code in (401, 403) diff --git a/backend/cases/tests/test_case_merge.py b/backend/cases/tests/test_case_merge.py index f1790b67..c3d5b9c1 100644 --- a/backend/cases/tests/test_case_merge.py +++ b/backend/cases/tests/test_case_merge.py @@ -7,7 +7,8 @@ matcher's follow-merge behavior. from __future__ import annotations -from datetime import datetime, timezone as dt_timezone +from datetime import datetime +from datetime import timezone as dt_timezone import pytest from django.contrib.contenttypes.models import ContentType @@ -94,9 +95,7 @@ class TestCaseMergeHappyPath: received_at=timezone.now(), ) - response = admin_client.post( - f"/api/cases/{duplicate.id}/merge/{primary.id}/" - ) + response = admin_client.post(f"/api/cases/{duplicate.id}/merge/{primary.id}/") assert response.status_code == 200, response.content body = response.json() assert body["error"] is False @@ -108,16 +107,21 @@ class TestCaseMergeHappyPath: assert body["source_case_id"] == str(duplicate.id) case_ct = ContentType.objects.get_for_model(Case) - assert Comment.objects.filter( - content_type=case_ct, object_id=primary.id - ).count() == 1 - assert Attachments.objects.filter( - content_type=case_ct, object_id=primary.id - ).count() == 1 + assert ( + Comment.objects.filter(content_type=case_ct, object_id=primary.id).count() + == 1 + ) + assert ( + Attachments.objects.filter( + content_type=case_ct, object_id=primary.id + ).count() + == 1 + ) assert EmailMessage.objects.filter(case=primary).count() == 1 - assert Comment.objects.filter( - content_type=case_ct, object_id=duplicate.id - ).count() == 0 + assert ( + Comment.objects.filter(content_type=case_ct, object_id=duplicate.id).count() + == 0 + ) primary.refresh_from_db() duplicate.refresh_from_db() @@ -137,9 +141,7 @@ class TestCaseMergeHappyPath: @pytest.mark.django_db class TestCaseMergeIdempotency: - def test_second_call_returns_already_merged( - self, admin_client, admin_user, org_a - ): + def test_second_call_returns_already_merged(self, admin_client, admin_user, org_a): primary = _make_case(org_a, admin_user, name="Primary") duplicate = _make_case(org_a, admin_user, name="Duplicate") @@ -183,9 +185,7 @@ class TestCaseMergeValidation: chain = admin_client.post(f"/api/cases/{a.id}/merge/{b.id}/") assert chain.status_code == 400 - def test_cross_org_merge_404s( - self, admin_client, admin_user, user_b, org_a, org_b - ): + def test_cross_org_merge_404s(self, admin_client, admin_user, user_b, org_a, org_b): local = _make_case(org_a, admin_user, name="Local") remote = _make_case(org_b, user_b, name="Remote") response = admin_client.post(f"/api/cases/{local.id}/merge/{remote.id}/") @@ -200,9 +200,7 @@ class TestCaseMergePermissions: response = admin_client.post(f"/api/cases/{a.id}/merge/{b.id}/") assert response.status_code == 200 - def test_creator_of_both_can_merge( - self, user_client, regular_user, org_a - ): + def test_creator_of_both_can_merge(self, user_client, regular_user, org_a): a = _make_case(org_a, regular_user, name="A") b = _make_case(org_a, regular_user, name="B") response = user_client.post(f"/api/cases/{a.id}/merge/{b.id}/") @@ -241,17 +239,13 @@ class TestCaseDetailRedirect: duplicate = _make_case(org_a, admin_user, name="Duplicate") admin_client.post(f"/api/cases/{duplicate.id}/merge/{primary.id}/") - response = admin_client.get( - f"/api/cases/{duplicate.id}/?show_merged=true" - ) + response = admin_client.get(f"/api/cases/{duplicate.id}/?show_merged=true") assert response.status_code == 200 body = response.json() assert "cases_obj" in body assert body.get("redirect_to") is None or "redirect_to" not in body - def test_primary_includes_merged_from_list( - self, admin_client, admin_user, org_a - ): + def test_primary_includes_merged_from_list(self, admin_client, admin_user, org_a): primary = _make_case(org_a, admin_user, name="Primary") duplicate = _make_case(org_a, admin_user, name="Duplicate") admin_client.post(f"/api/cases/{duplicate.id}/merge/{primary.id}/") diff --git a/backend/cases/tests/test_case_solutions.py b/backend/cases/tests/test_case_solutions.py index 3a1ed2d7..09d17580 100644 --- a/backend/cases/tests/test_case_solutions.py +++ b/backend/cases/tests/test_case_solutions.py @@ -96,8 +96,6 @@ class TestCaseSolutionLinking: def test_unlink_solution(self, admin_client, case_a, org_a, admin_user): sol = self._solution(org_a, admin_user) case_a.solutions.add(sol) - response = admin_client.delete( - f"/api/cases/{case_a.pk}/solutions/{sol.pk}/" - ) + response = admin_client.delete(f"/api/cases/{case_a.pk}/solutions/{sol.pk}/") assert response.status_code == 204 assert sol not in case_a.solutions.all() diff --git a/backend/cases/tests/test_cases_bulk.py b/backend/cases/tests/test_cases_bulk.py index 966db477..b01e9e41 100644 --- a/backend/cases/tests/test_cases_bulk.py +++ b/backend/cases/tests/test_cases_bulk.py @@ -1,6 +1,7 @@ """Tests for the bulk update / bulk delete Case endpoints.""" import pytest + from conftest import rls_org diff --git a/backend/cases/tests/test_cases_kanban.py b/backend/cases/tests/test_cases_kanban.py index c0679a90..539f7420 100644 --- a/backend/cases/tests/test_cases_kanban.py +++ b/backend/cases/tests/test_cases_kanban.py @@ -10,7 +10,6 @@ from rest_framework import status from cases.models import Case, CasePipeline, CaseStage - PIPELINES_URL = "/api/cases/pipelines/" KANBAN_URL = "/api/cases/kanban/" @@ -38,9 +37,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) @pytest.fixture @@ -147,9 +144,7 @@ class TestCasePipeline: assert response.status_code == status.HTTP_200_OK assert response.data["name"] == "Renamed Pipeline" - def test_update_pipeline_non_admin_forbidden( - self, user_client, pipeline - ): + def test_update_pipeline_non_admin_forbidden(self, user_client, pipeline): """Non-admin should not be able to update a pipeline.""" response = user_client.put( _pipeline_detail_url(pipeline.pk), @@ -413,9 +408,7 @@ class TestCaseKanban: org=org_a, created_by=admin_user, ) - response = admin_client.get( - f"{KANBAN_URL}?assigned_to={admin_profile.id}" - ) + response = admin_client.get(f"{KANBAN_URL}?assigned_to={admin_profile.id}") assert response.status_code == status.HTTP_200_OK assert response.data["total_cases"] == 1 diff --git a/backend/cases/tests/test_csat.py b/backend/cases/tests/test_csat.py index c8e260d5..c988a93f 100644 --- a/backend/cases/tests/test_csat.py +++ b/backend/cases/tests/test_csat.py @@ -83,7 +83,10 @@ class TestSendCsatSurvey: def test_skips_when_no_contact_email(self, org_a): # Case with no contacts at all. case = Case.objects.create( - org=org_a, name="No contact", status="Closed", priority="Normal", + org=org_a, + name="No contact", + status="Closed", + priority="Normal", closed_on=timezone.localdate(), ) result = send_csat_survey(str(case.id), str(case.org_id)) @@ -260,11 +263,16 @@ class TestCsatAggregate: ) # One un-responded survey, must not count. case = Case.objects.create( - org=org_a, name="Cx", status="Closed", priority="Normal", + org=org_a, + name="Cx", + status="Closed", + priority="Normal", closed_on=timezone.localdate(), ) CsatSurvey.objects.create( - org=org_a, case=case, contact=contact_with_email, + org=org_a, + case=case, + contact=contact_with_email, token_hash=hash_csat_token("tx"), sent_at=timezone.now(), expires_at=timezone.now() + timedelta(days=30), @@ -285,13 +293,15 @@ class TestCsatAggregate: class TestCsatFeedsAnalytics: - def test_csat_avg_in_agents_payload( - self, org_a, admin_profile, contact_with_email - ): - from datetime import datetime, timezone as dt_timezone + def test_csat_avg_in_agents_payload(self, org_a, admin_profile, contact_with_email): + from datetime import datetime + from datetime import timezone as dt_timezone case = Case.objects.create( - org=org_a, name="A", status="Closed", priority="Normal", + org=org_a, + name="A", + status="Closed", + priority="Normal", closed_on=timezone.localdate(), ) case.assigned_to.add(admin_profile) diff --git a/backend/cases/tests/test_custom_fields.py b/backend/cases/tests/test_custom_fields.py index b0ce3168..01a28d41 100644 --- a/backend/cases/tests/test_custom_fields.py +++ b/backend/cases/tests/test_custom_fields.py @@ -59,7 +59,9 @@ class TestCaseCreateWithCustomFields: assert case.custom_fields == {"severity": "S1"} @patch("cases.views.send_email_to_assigned_user") - def test_create_rejects_invalid_dropdown_value(self, mock_email, admin_client, org_a): + def test_create_rejects_invalid_dropdown_value( + self, mock_email, admin_client, org_a + ): _make_severity_def(org_a) response = admin_client.post( CASES_LIST_URL, @@ -164,9 +166,7 @@ class TestCaseListFilter: @pytest.mark.django_db class TestCaseDetailResponse: - def test_detail_includes_definitions_and_values( - self, admin_client, case_a, org_a - ): + def test_detail_includes_definitions_and_values(self, admin_client, case_a, org_a): _make_severity_def(org_a) case_a.custom_fields = {"severity": "S2"} case_a.save() @@ -178,11 +178,11 @@ class TestCaseDetailResponse: keys = [d["key"] for d in body["custom_field_definitions"]] assert "severity" in keys - def test_detail_excludes_inactive_definitions( - self, admin_client, case_a, org_a - ): + def test_detail_excludes_inactive_definitions(self, admin_client, case_a, org_a): _make_severity_def(org_a) - _make_severity_def(org_a, key="legacy", field_type="text", options=None, is_active=False) + _make_severity_def( + org_a, key="legacy", field_type="text", options=None, is_active=False + ) response = admin_client.get(f"{CASES_LIST_URL}{case_a.id}/") keys = [d["key"] for d in response.json()["custom_field_definitions"]] assert "severity" in keys diff --git a/backend/cases/tests/test_escalation.py b/backend/cases/tests/test_escalation.py index cb80279c..39f04a6e 100644 --- a/backend/cases/tests/test_escalation.py +++ b/backend/cases/tests/test_escalation.py @@ -12,8 +12,10 @@ import pytest from django.utils import timezone from cases.models import Case, EscalationPolicy -from cases.tasks import ESCALATION_COUNT_CAP, scan_for_breached_cases as _scan_for_breached_cases -from conftest import restore_rls_context +from cases.tasks import ESCALATION_COUNT_CAP +from cases.tasks import scan_for_breached_cases as _scan_for_breached_cases +from common.models import Activity, Profile, Teams, User +from conftest import restore_rls_context, rls_org # The task clears `app.current_org` when it finishes, correctly: it walks every @@ -27,8 +29,6 @@ def scan_for_breached_cases(*args, **kwargs): finally: restore_rls_context() -from common.models import Activity, Profile, Teams, User -from conftest import rls_org POLICIES_URL = "/api/cases/escalation-policies/" diff --git a/backend/cases/tests/test_import.py b/backend/cases/tests/test_import.py index d6d6d908..87938303 100644 --- a/backend/cases/tests/test_import.py +++ b/backend/cases/tests/test_import.py @@ -8,8 +8,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile from accounts.models import Account from cases.models import Case from common.models import Tags, Teams -from contacts.models import Contact from conftest import rls_org +from contacts.models import Contact def _csv(headers: list[str], rows: list[list[str]]) -> SimpleUploadedFile: @@ -30,9 +30,7 @@ def _csv(headers: list[str], rows: list[list[str]]) -> SimpleUploadedFile: @pytest.fixture def account_a(org_a, admin_user): - return Account.objects.create( - name="Acme Corp", org=org_a, created_by=admin_user - ) + return Account.objects.create(name="Acme Corp", org=org_a, created_by=admin_user) @pytest.fixture @@ -101,9 +99,7 @@ class TestImportPreview: assert (2, "priority") in fields assert (3, "account_name") in fields - def test_duplicate_within_org_rejected( - self, admin_client, case_a, admin_profile - ): + def test_duplicate_within_org_rejected(self, admin_client, case_a, admin_profile): # case_a.name == "Bug in login page" csv_file = _csv( ["name", "status", "priority"], @@ -154,18 +150,14 @@ class TestImportPreview: def test_non_admin_without_sales_access_forbidden(self, user_client): # default user_profile has no has_sales_access - csv_file = _csv( - ["name", "status", "priority"], [["X", "New", "High"]] - ) + csv_file = _csv(["name", "status", "priority"], [["X", "New", "High"]]) response = user_client.post( "/api/cases/import/preview/", {"file": csv_file}, format="multipart" ) assert response.status_code == 403 def test_unauthenticated_rejected(self, unauthenticated_client): - csv_file = _csv( - ["name", "status", "priority"], [["X", "New", "High"]] - ) + csv_file = _csv(["name", "status", "priority"], [["X", "New", "High"]]) response = unauthenticated_client.post( "/api/cases/import/preview/", {"file": csv_file}, format="multipart" ) @@ -219,9 +211,7 @@ class TestImportCommit: assert Case.objects.filter(org=org_a).count() == 0 def test_non_admin_without_sales_access_forbidden(self, user_client): - csv_file = _csv( - ["name", "status", "priority"], [["X", "New", "High"]] - ) + csv_file = _csv(["name", "status", "priority"], [["X", "New", "High"]]) response = user_client.post( "/api/cases/import/commit/", {"file": csv_file}, format="multipart" ) @@ -232,20 +222,17 @@ class TestImportCommit: ): # Promote the regular user with sales access, build a fresh client. from rest_framework.test import APIClient + from common.serializer import OrgAwareRefreshToken user_profile.has_sales_access = True user_profile.save(update_fields=["has_sales_access"]) client = APIClient() - token = OrgAwareRefreshToken.for_user_and_org( - regular_user, org_a, user_profile - ) + token = OrgAwareRefreshToken.for_user_and_org(regular_user, org_a, user_profile) client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}") - csv_file = _csv( - ["name", "status", "priority"], [["Allowed", "New", "High"]] - ) + csv_file = _csv(["name", "status", "priority"], [["Allowed", "New", "High"]]) response = client.post( "/api/cases/import/commit/", {"file": csv_file}, format="multipart" ) @@ -354,14 +341,22 @@ class TestImportEdgeCases: # cost one bulk SELECT regardless of row count. We don't pin an exact # number (auth + RLS + view-level queries vary), but capping at a small # constant prevents the N+1 from regressing. - Account.objects.create(name="Acme Corp", org=org_a, created_by=admin_profile.user) - Contact.objects.create( - first_name="A", last_name="B", email="a@x.test", - org=org_a, created_by=admin_profile.user, + Account.objects.create( + name="Acme Corp", org=org_a, created_by=admin_profile.user ) Contact.objects.create( - first_name="C", last_name="D", email="c@x.test", - org=org_a, created_by=admin_profile.user, + first_name="A", + last_name="B", + email="a@x.test", + org=org_a, + created_by=admin_profile.user, + ) + Contact.objects.create( + first_name="C", + last_name="D", + email="c@x.test", + org=org_a, + created_by=admin_profile.user, ) Teams.objects.create(name="Support", description="", org=org_a) @@ -370,7 +365,14 @@ class TestImportEdgeCases: for i in range(50) ] csv_file = _csv( - ["name", "status", "priority", "account_name", "contact_emails", "team_names"], + [ + "name", + "status", + "priority", + "account_name", + "contact_emails", + "team_names", + ], rows, ) # Bulk prefetch should keep this well under 50 queries even at 50 rows. diff --git a/backend/cases/tests/test_inbound_email.py b/backend/cases/tests/test_inbound_email.py index 97861f7e..b50d1a46 100644 --- a/backend/cases/tests/test_inbound_email.py +++ b/backend/cases/tests/test_inbound_email.py @@ -17,8 +17,8 @@ from cases.inbound.spam import should_drop from cases.inbound.threading import find_existing_case, short_case_id from cases.models import Case, EmailMessage, InboundMailbox from common.models import Profile, User -from contacts.models import Contact from conftest import rls_org +from contacts.models import Contact MAILBOXES_URL = "/api/cases/mailboxes/" @@ -612,9 +612,7 @@ class TestMailboxAPI: assert "webhook_secret" not in detail.json() assert detail.json()["has_webhook_secret"] is True - def test_has_webhook_secret_is_false_when_none_is_stored( - self, admin_client, org_a - ): + def test_has_webhook_secret_is_false_when_none_is_stored(self, admin_client, org_a): _make_mailbox(org_a, webhook_secret="") row = admin_client.get(MAILBOXES_URL).json()["mailboxes"][0] assert row["has_webhook_secret"] is False diff --git a/backend/cases/tests/test_internal_notes.py b/backend/cases/tests/test_internal_notes.py index 30bb7333..1381c5dc 100644 --- a/backend/cases/tests/test_internal_notes.py +++ b/backend/cases/tests/test_internal_notes.py @@ -26,9 +26,7 @@ def _make_comment(case, profile, *, text="hi", is_internal=False): @pytest.mark.django_db class TestCommentSplitResponse: - def test_get_splits_public_and_internal( - self, admin_client, case_a, admin_profile - ): + def test_get_splits_public_and_internal(self, admin_client, case_a, admin_profile): _make_comment(case_a, admin_profile, text="public reply", is_internal=False) _make_comment(case_a, admin_profile, text="agent only", is_internal=True) @@ -129,8 +127,7 @@ class TestVisibilityFlipAuditLogged: entity_type="Case", entity_id=case_a.pk, action="COMMENT", - ) - .order_by("created_at") + ).order_by("created_at") ) assert len(flip_rows) == baseline_count + 1 latest = flip_rows[-1] diff --git a/backend/cases/tests/test_kb_suggestions.py b/backend/cases/tests/test_kb_suggestions.py index bef2d675..9e25cc34 100644 --- a/backend/cases/tests/test_kb_suggestions.py +++ b/backend/cases/tests/test_kb_suggestions.py @@ -148,9 +148,7 @@ class TestSolutionSuggestionsSnippet: status="approved", is_published=True, ) - resp = admin_client.get( - f"/api/cases/{case_a.id}/solution-suggestions/?q=abc" - ) + resp = admin_client.get(f"/api/cases/{case_a.id}/solution-suggestions/?q=abc") for row in resp.data["results"]: if row["title"] == "Big article": assert len(row["snippet"]) <= 200 @@ -158,9 +156,7 @@ class TestSolutionSuggestionsSnippet: class TestSolutionSuggestionsRls: - def test_cross_org_solutions_invisible( - self, admin_client, case_a, org_b - ): + def test_cross_org_solutions_invisible(self, admin_client, case_a, org_b): # Seeded into another tenant: the insert-check policy compares # against `app.current_org`, so writing this row means being that # tenant for the length of the write. @@ -178,9 +174,7 @@ class TestSolutionSuggestionsRls: titles = [r["title"] for r in resp.data["results"]] assert "Other org password reset" not in titles - def test_cross_org_case_returns_404( - self, admin_client, case_b - ): + def test_cross_org_case_returns_404(self, admin_client, case_b): # admin_client is in org_a; case_b lives in org_b. resp = admin_client.get( f"/api/cases/{case_b.id}/solution-suggestions/?q=password" diff --git a/backend/cases/tests/test_parent_child.py b/backend/cases/tests/test_parent_child.py index 32c492dd..38ce41d7 100644 --- a/backend/cases/tests/test_parent_child.py +++ b/backend/cases/tests/test_parent_child.py @@ -43,9 +43,7 @@ def _make_case( def _activity_for(case, action): - return Activity.objects.filter( - entity_type="Case", entity_id=case.id, action=action - ) + return Activity.objects.filter(entity_type="Case", entity_id=case.id, action=action) @pytest.mark.django_db @@ -81,9 +79,7 @@ class TestCleanGuards: d.clean() def test_duplicate_parent_rejected(self, admin_user, org_a): - merged = _make_case( - org_a, admin_user, name="Merged", status_value="Duplicate" - ) + merged = _make_case(org_a, admin_user, name="Merged", status_value="Duplicate") case = _make_case(org_a, admin_user, name="Live") case.parent = merged with pytest.raises(ValidationError): @@ -92,9 +88,7 @@ class TestCleanGuards: @pytest.mark.django_db class TestSerializer: - def test_parent_summary_and_child_count( - self, admin_user, admin_profile, org_a - ): + def test_parent_summary_and_child_count(self, admin_user, admin_profile, org_a): from cases.serializer import CaseSerializer root = _make_case(org_a, admin_user, name="Root", is_problem=True) @@ -178,9 +172,7 @@ class TestLinkEndpoint: a = _activity_for(child, "LINKED_PARENT").first() assert a.metadata["parent_id"] == str(parent.id) - def test_unlink_clears_parent( - self, admin_client, admin_user, admin_profile, org_a - ): + def test_unlink_clears_parent(self, admin_client, admin_user, admin_profile, org_a): parent = _make_case(org_a, admin_user, name="P") child = _make_case(org_a, admin_user, name="C", parent=parent) resp = admin_client.post( @@ -235,12 +227,8 @@ class TestLinkEndpoint: ) assert resp.status_code == 400 - def test_link_to_duplicate_rejected( - self, admin_client, admin_user, org_a - ): - merged = _make_case( - org_a, admin_user, name="Merged", status_value="Duplicate" - ) + def test_link_to_duplicate_rejected(self, admin_client, admin_user, org_a): + merged = _make_case(org_a, admin_user, name="Merged", status_value="Duplicate") case = _make_case(org_a, admin_user, name="Active") resp = admin_client.post( f"/api/cases/{case.id}/link/", @@ -306,9 +294,7 @@ class TestCloseWithChildren: child.refresh_from_db() assert child.status == "Closed" - def test_already_closed_descendants_skipped( - self, admin_client, admin_user, org_a - ): + def test_already_closed_descendants_skipped(self, admin_client, admin_user, org_a): from datetime import date parent = _make_case(org_a, admin_user, name="P", is_problem=True) @@ -331,9 +317,7 @@ class TestCloseWithChildren: assert resp.json()["cascaded_case_ids"] == [] def test_duplicate_parent_rejected(self, admin_client, admin_user, org_a): - merged = _make_case( - org_a, admin_user, name="Merged", status_value="Duplicate" - ) + merged = _make_case(org_a, admin_user, name="Merged", status_value="Duplicate") resp = admin_client.post( f"/api/cases/{merged.id}/close-with-children/", {"cascade": True}, diff --git a/backend/cases/tests/test_reopen.py b/backend/cases/tests/test_reopen.py index 0cb5e193..e9c5a71f 100644 --- a/backend/cases/tests/test_reopen.py +++ b/backend/cases/tests/test_reopen.py @@ -237,7 +237,9 @@ class TestReopenAnalytics: # against `app.current_org`, so writing this row means being that # tenant for the length of the write. with rls_org(org): - return Case.objects.create(org=org, name="C", status="New", priority="Normal") + return Case.objects.create( + org=org, name="C", status="New", priority="Normal" + ) def test_reopened_last_30d_counts_reopens(self, admin_client, org_a): case = self._make_case(org_a) diff --git a/backend/cases/tests/test_sla_pause.py b/backend/cases/tests/test_sla_pause.py index de1b2995..704fa73d 100644 --- a/backend/cases/tests/test_sla_pause.py +++ b/backend/cases/tests/test_sla_pause.py @@ -9,7 +9,6 @@ from django.utils import timezone from business_hours.models import BusinessCalendar - _UTC = ZoneInfo("UTC") diff --git a/backend/cases/tests/test_solutions.py b/backend/cases/tests/test_solutions.py index 148f17a8..33e2e475 100644 --- a/backend/cases/tests/test_solutions.py +++ b/backend/cases/tests/test_solutions.py @@ -10,7 +10,6 @@ from rest_framework import status from cases.models import Solution - SOLUTIONS_LIST_URL = "/api/cases/solutions/" @@ -33,9 +32,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) @pytest.fixture @@ -136,9 +133,7 @@ class TestSolutionDetailView: def test_get_solution_not_found(self, admin_client): """GET with non-existent pk should return 404.""" - response = admin_client.get( - _detail_url("00000000-0000-0000-0000-000000000001") - ) + response = admin_client.get(_detail_url("00000000-0000-0000-0000-000000000001")) assert response.status_code == status.HTTP_404_NOT_FOUND def test_update_solution_not_found(self, admin_client): diff --git a/backend/cases/tests/test_time_tracking.py b/backend/cases/tests/test_time_tracking.py index 52831405..e5cfb916 100644 --- a/backend/cases/tests/test_time_tracking.py +++ b/backend/cases/tests/test_time_tracking.py @@ -22,7 +22,9 @@ from django.utils import timezone from accounts.models import Account from cases.models import Case, TimeEntry from cases.tasks import auto_stop_stale_timers as _auto_stop_stale_timers +from common.models import Activity from conftest import restore_rls_context +from invoices.models import Invoice, InvoiceLineItem # The task clears `app.current_org` when it finishes, correctly: it walks every @@ -36,9 +38,6 @@ def auto_stop_stale_timers(*args, **kwargs): finally: restore_rls_context() -from common.models import Activity -from invoices.models import Invoice, InvoiceLineItem - def _make_case(org, creator, name="Sample case"): with impersonate(creator): @@ -105,9 +104,7 @@ class TestModelConstraints: ) assert TimeEntry.objects.filter(case=case, ended_at__isnull=True).count() == 2 - def test_save_recomputes_duration_minutes( - self, admin_user, admin_profile, org_a - ): + def test_save_recomputes_duration_minutes(self, admin_user, admin_profile, org_a): case = _make_case(org_a, admin_user) start = timezone.now() - timedelta(minutes=45) end = timezone.now() - timedelta(minutes=15) @@ -267,9 +264,7 @@ class TestManualEntry: == 1 ) - def test_end_before_start_rejected( - self, admin_client, admin_user, org_a - ): + def test_end_before_start_rejected(self, admin_client, admin_user, org_a): case = _make_case(org_a, admin_user) start = timezone.now() end = start - timedelta(minutes=10) @@ -298,9 +293,7 @@ class TestManualEntry: @pytest.mark.django_db class TestDetail: - def test_owner_can_edit( - self, admin_client, admin_user, admin_profile, org_a - ): + def test_owner_can_edit(self, admin_client, admin_user, admin_profile, org_a): case = _make_case(org_a, admin_user) entry = _entry( case, @@ -607,17 +600,13 @@ class TestSignalAndAutoStop: admin_profile, started_at=timezone.now() - timedelta(minutes=10), ) - admin_client.post( - f"/api/time-entries/{entry.id}/stop/", {}, format="json" - ) + admin_client.post(f"/api/time-entries/{entry.id}/stop/", {}, format="json") assert ( Activity.objects.filter(entity_id=case.id, action="TIME_LOGGED").count() == 1 ) - def test_auto_stop_kills_stale_timer( - self, admin_user, admin_profile, org_a - ): + def test_auto_stop_kills_stale_timer(self, admin_user, admin_profile, org_a): case = _make_case(org_a, admin_user) old_start = timezone.now() - timedelta(hours=15) entry = TimeEntry.objects.create( @@ -629,9 +618,7 @@ class TestSignalAndAutoStop: assert entry.ended_at is not None assert entry.auto_stopped is True - def test_auto_stop_leaves_recent_timer( - self, admin_user, admin_profile, org_a - ): + def test_auto_stop_leaves_recent_timer(self, admin_user, admin_profile, org_a): case = _make_case(org_a, admin_user) recent_start = timezone.now() - timedelta(hours=2) entry = TimeEntry.objects.create( diff --git a/backend/cases/tests/test_watchers.py b/backend/cases/tests/test_watchers.py index 0aa8459e..4042e871 100644 --- a/backend/cases/tests/test_watchers.py +++ b/backend/cases/tests/test_watchers.py @@ -22,9 +22,7 @@ class TestCaseWatcherModel: assert admin_profile in case_a.watchers.all() def test_unique_per_case_profile(self, case_a, admin_profile): - CaseWatcher.objects.create( - case=case_a, profile=admin_profile, org=case_a.org - ) + CaseWatcher.objects.create(case=case_a, profile=admin_profile, org=case_a.org) with pytest.raises(IntegrityError): CaseWatcher.objects.create( case=case_a, profile=admin_profile, org=case_a.org @@ -40,25 +38,19 @@ class TestCaseWatcherModel: assert "mention" in str(w) def test_case_cascade_deletes_watcher(self, case_a, admin_profile): - CaseWatcher.objects.create( - case=case_a, profile=admin_profile, org=case_a.org - ) + CaseWatcher.objects.create(case=case_a, profile=admin_profile, org=case_a.org) case_pk = case_a.pk case_a.delete() assert not CaseWatcher.objects.filter(case_id=case_pk).exists() def test_profile_cascade_deletes_watcher(self, case_a, admin_profile): - CaseWatcher.objects.create( - case=case_a, profile=admin_profile, org=case_a.org - ) + CaseWatcher.objects.create(case=case_a, profile=admin_profile, org=case_a.org) profile_pk = admin_profile.pk admin_profile.delete() assert not CaseWatcher.objects.filter(profile_id=profile_pk).exists() def test_watchers_m2m_query(self, case_a, admin_profile, user_profile): - CaseWatcher.objects.create( - case=case_a, profile=admin_profile, org=case_a.org - ) + CaseWatcher.objects.create(case=case_a, profile=admin_profile, org=case_a.org) # user_profile is NOT watching watching_cases = Case.objects.filter(watchers=admin_profile) not_watching = Case.objects.filter(watchers=user_profile) @@ -74,25 +66,18 @@ class TestWatchAPI: body = r.json() assert body["watching"] is True assert body["subscribed_via"] == "manual" - assert CaseWatcher.objects.filter( - case=case_a, profile=admin_profile - ).exists() + assert CaseWatcher.objects.filter(case=case_a, profile=admin_profile).exists() def test_post_idempotent(self, admin_client, case_a, admin_profile): admin_client.post(f"/api/cases/{case_a.id}/watch/") r = admin_client.post(f"/api/cases/{case_a.id}/watch/") assert r.status_code == 200 # already watching → 200, not 201 assert ( - CaseWatcher.objects.filter( - case=case_a, profile=admin_profile - ).count() - == 1 + CaseWatcher.objects.filter(case=case_a, profile=admin_profile).count() == 1 ) def test_delete_removes_watch(self, admin_client, case_a, admin_profile): - CaseWatcher.objects.create( - case=case_a, profile=admin_profile, org=case_a.org - ) + CaseWatcher.objects.create(case=case_a, profile=admin_profile, org=case_a.org) r = admin_client.delete(f"/api/cases/{case_a.id}/watch/") assert r.status_code == 204 assert not CaseWatcher.objects.filter( @@ -112,9 +97,7 @@ class TestWatchAPI: r = unauthenticated_client.post(f"/api/cases/{case_a.id}/watch/") assert r.status_code in (401, 403) - def test_non_admin_who_cannot_see_case_blocked( - self, user_client, case_a - ): + def test_non_admin_who_cannot_see_case_blocked(self, user_client, case_a): # user_profile is not admin, not creator, not assigned, not watching. # Should not be able to subscribe to a case they cannot see. r = user_client.post(f"/api/cases/{case_a.id}/watch/") @@ -126,9 +109,7 @@ class TestWatchersListAPI: def test_lists_all_watchers( self, admin_client, case_a, admin_profile, user_profile ): - CaseWatcher.objects.create( - case=case_a, profile=admin_profile, org=case_a.org - ) + CaseWatcher.objects.create(case=case_a, profile=admin_profile, org=case_a.org) CaseWatcher.objects.create( case=case_a, profile=user_profile, @@ -159,9 +140,7 @@ class TestWatchingListAPI: admin_profile, user_profile, ): - CaseWatcher.objects.create( - case=case_a, profile=admin_profile, org=case_a.org - ) + CaseWatcher.objects.create(case=case_a, profile=admin_profile, org=case_a.org) # Another user watches a different case, must not leak. CaseWatcher.objects.create( case=case_b_same_org, @@ -189,16 +168,12 @@ class TestVisibilityAllowance: ): # user_profile is not admin, not creator, not assigned. Normally # invisible. Subscribing them as a watcher should grant read access. - CaseWatcher.objects.create( - case=case_a, profile=user_profile, org=case_a.org - ) + CaseWatcher.objects.create(case=case_a, profile=user_profile, org=case_a.org) r = user_client.delete(f"/api/cases/{case_a.id}/watch/") # Watcher allowance lets them at least UNWATCH. assert r.status_code == 204 - def test_watcher_sees_case_in_main_list( - self, user_client, case_a, user_profile - ): + def test_watcher_sees_case_in_main_list(self, user_client, case_a, user_profile): # Non-admin who isn't creator or assigned is invisible by default. # Watching the case must surface it in the main list. before = user_client.get("/api/cases/") @@ -206,9 +181,7 @@ class TestVisibilityAllowance: before_ids = [c["id"] for c in before.json().get("cases", [])] assert str(case_a.id) not in before_ids - CaseWatcher.objects.create( - case=case_a, profile=user_profile, org=case_a.org - ) + CaseWatcher.objects.create(case=case_a, profile=user_profile, org=case_a.org) after = user_client.get("/api/cases/") assert after.status_code == 200 after_ids = [c["id"] for c in after.json().get("cases", [])] diff --git a/backend/cases/tests/test_watchers_mentions.py b/backend/cases/tests/test_watchers_mentions.py index a1b6d7ee..b8b91088 100644 --- a/backend/cases/tests/test_watchers_mentions.py +++ b/backend/cases/tests/test_watchers_mentions.py @@ -1,7 +1,8 @@ """Mention parser + comment-driven notification dispatch tests.""" -import pytest from datetime import timedelta + +import pytest from django.contrib.contenttypes.models import ContentType from django.utils import timezone @@ -96,9 +97,10 @@ class TestDispatchForComment: _comment(case_a, "hey @bob please review", by=admin_profile) # Sanity: notifications were dispatched via the signal handler. - assert Notification.objects.filter( - recipient=bob, verb="case.mentioned" - ).count() == 1 + assert ( + Notification.objects.filter(recipient=bob, verb="case.mentioned").count() + == 1 + ) # Auto-watch row. watch = CaseWatcher.objects.get(case=case_a, profile=bob) assert watch.subscribed_via == "mention" @@ -110,9 +112,7 @@ class TestDispatchForComment: recipient=admin_profile, verb="case.mentioned" ).exists() - def test_unknown_username_silently_ignored( - self, case_a, admin_profile - ): + def test_unknown_username_silently_ignored(self, case_a, admin_profile): _comment(case_a, "@nosuchuser ping?", by=admin_profile) # No new mention notifications, no extra watcher rows assert Notification.objects.filter(verb="case.mentioned").count() == 0 @@ -121,9 +121,7 @@ class TestDispatchForComment: self, case_a, admin_profile, org_a ): bob = _make_profile(org_a, email="bob@org.com") - _comment( - case_a, "Sent to bob@org.com about this", by=admin_profile - ) + _comment(case_a, "Sent to bob@org.com about this", by=admin_profile) # @ preceded by alphanumeric, must NOT be parsed as a mention. assert not Notification.objects.filter( recipient=bob, verb="case.mentioned" @@ -136,9 +134,10 @@ class TestDispatchForComment: _comment(case_a, "@bob first", by=admin_profile) # Second mention within the rate-limit window should be skipped. _comment(case_a, "@bob again immediately", by=admin_profile) - assert Notification.objects.filter( - recipient=bob, verb="case.mentioned" - ).count() == 1 + assert ( + Notification.objects.filter(recipient=bob, verb="case.mentioned").count() + == 1 + ) def test_mention_rate_limit_does_not_block_after_window( self, case_a, admin_profile, org_a @@ -150,9 +149,10 @@ class TestDispatchForComment: created_at=timezone.now() - timedelta(minutes=2) ) _comment(case_a, "@bob again", by=admin_profile) - assert Notification.objects.filter( - recipient=bob, verb="case.mentioned" - ).count() == 2 + assert ( + Notification.objects.filter(recipient=bob, verb="case.mentioned").count() + == 2 + ) def test_watchers_get_case_commented_notification( self, case_a, admin_profile, org_a @@ -162,17 +162,18 @@ class TestDispatchForComment: _comment(case_a, "important update", by=admin_profile) - assert Notification.objects.filter( - recipient=watcher, verb="case.commented" - ).count() == 1 + assert ( + Notification.objects.filter( + recipient=watcher, verb="case.commented" + ).count() + == 1 + ) def test_actor_does_not_get_their_own_comment_notification( self, case_a, admin_profile, org_a ): # Actor is also a watcher, should still NOT receive case.commented. - CaseWatcher.objects.create( - case=case_a, profile=admin_profile, org=org_a - ) + CaseWatcher.objects.create(case=case_a, profile=admin_profile, org=org_a) _comment(case_a, "self note", by=admin_profile) assert not Notification.objects.filter( recipient=admin_profile, verb="case.commented" @@ -185,9 +186,10 @@ class TestDispatchForComment: # Pre-existing watch shouldn't double up either. CaseWatcher.objects.create(case=case_a, profile=bob, org=org_a) _comment(case_a, "@bob look at this", by=admin_profile) - assert Notification.objects.filter( - recipient=bob, verb="case.mentioned" - ).count() == 1 + assert ( + Notification.objects.filter(recipient=bob, verb="case.mentioned").count() + == 1 + ) assert not Notification.objects.filter( recipient=bob, verb="case.commented" ).exists() diff --git a/backend/cases/time_views.py b/backend/cases/time_views.py index 2a14699e..43095651 100644 --- a/backend/cases/time_views.py +++ b/backend/cases/time_views.py @@ -52,8 +52,8 @@ class TimeEntryListCreateView(APIView): def get(self, request, pk): case = get_object_or_404(Case, id=pk, org=request.profile.org) - entries = _visible_entry_qs(request.profile).filter(case=case).order_by( - "-started_at" + entries = ( + _visible_entry_qs(request.profile).filter(case=case).order_by("-started_at") ) return Response(TimeEntrySerializer(entries, many=True).data) @@ -75,9 +75,7 @@ class TimeEntryListCreateView(APIView): {"detail": str(exc).splitlines()[0][:200]}, status=status.HTTP_400_BAD_REQUEST, ) - return Response( - TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED - ) + return Response(TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED) class TimeEntryStartView(APIView): @@ -114,9 +112,7 @@ class TimeEntryStartView(APIView): description=(request.data.get("description") or "").strip(), billable=bool(request.data.get("billable", False)), ) - return Response( - TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED - ) + return Response(TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED) class TimeSummaryView(APIView): @@ -178,9 +174,7 @@ class TimeEntryDetailView(APIView): def put(self, request, pk): entry = self._get_entry(request, pk) if entry is None: - return Response( - {"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) if entry is False: return Response( {"detail": "Not authorized to edit this entry."}, @@ -197,9 +191,7 @@ class TimeEntryDetailView(APIView): def delete(self, request, pk): entry = self._get_entry(request, pk) if entry is None: - return Response( - {"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) if entry is False: return Response( {"detail": "Not authorized to delete this entry."}, @@ -227,9 +219,7 @@ class TimeEntryStopView(APIView): .first() ) if entry is None: - return Response( - {"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) if entry.profile_id != request.profile.id and not is_org_admin(request.profile): return Response( {"detail": "Not authorized to stop this entry."}, @@ -290,9 +280,7 @@ class TimesheetView(APIView): start = self._parse_date(request.query_params.get("start")) end = self._parse_date(request.query_params.get("end")) except ValueError as exc: - return Response( - {"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) if start is None or end is None: today = timezone.localdate() # Default to this Mon..Sun (ISO week). diff --git a/backend/cases/unmerge_views.py b/backend/cases/unmerge_views.py index cdaab0d5..50ef5bfd 100644 --- a/backend/cases/unmerge_views.py +++ b/backend/cases/unmerge_views.py @@ -137,7 +137,9 @@ class CaseUnmergeView(APIView): prior_status = record.get("source_prior_status") or "New" prior_closed_on_raw = record.get("source_prior_closed_on") - prior_closed_on = parse_date(prior_closed_on_raw) if prior_closed_on_raw else None + prior_closed_on = ( + parse_date(prior_closed_on_raw) if prior_closed_on_raw else None + ) target_id_for_activity = str(target.id) source.merged_into = None diff --git a/backend/common/app_urls/__init__.py b/backend/common/app_urls/__init__.py index 8dc0ef0b..59ffcb83 100644 --- a/backend/common/app_urls/__init__.py +++ b/backend/common/app_urls/__init__.py @@ -33,7 +33,5 @@ urlpatterns = [ # Public CSAT (Tier 2 csat): anonymous, token-scoped. Lives outside # any app namespace because the customer reaches it from an emailed # link with no auth context. - path( - "public/csat//", PublicCsatView.as_view(), name="public_csat" - ), + path("public/csat//", PublicCsatView.as_view(), name="public_csat"), ] diff --git a/backend/common/audit_log.py b/backend/common/audit_log.py index cec5405e..f3139938 100644 --- a/backend/common/audit_log.py +++ b/backend/common/audit_log.py @@ -161,7 +161,7 @@ class AuditLogger: event_type, user_id, org_id, - request_info.get('ip_address'), + request_info.get("ip_address"), success, ) diff --git a/backend/common/base.py b/backend/common/base.py index b507e9e8..a35c5d38 100644 --- a/backend/common/base.py +++ b/backend/common/base.py @@ -75,20 +75,44 @@ class BaseModel(AuditModel): abstract = True def save(self, *args, **kwargs): + """Stamp the audit columns from the request user, when there is one. + + `crum.get_current_user` reads a thread local that only the request + middleware fills, so it is None in a Celery worker, a management + command, the shell and a data migration. The previous version answered + that case by assigning None to both columns, which did two things it did + not mean to: + + * it discarded a `created_by=` the caller had passed explicitly, and the + callers with no request are exactly the ones that have to say who to + credit. `create_invoice_history` passes `created_by=invoice.created_by` + and `updated_by=updated_by` from a task, so the invoice audit trail, + whose entire job is recording who changed an invoice, recorded nobody; + * on an UPDATE it assigned None to `created_by` as well, so any save + from a worker permanently erased an existing row's creator. The + in-request branch below is careful not to touch `created_by` on + update; the no-request branch overwrote it every time. + + With a request user present the behaviour is deliberately unchanged: the + request user wins over anything in the payload. Fifteen serializers still + leave `created_by` or `updated_by` writable, and this assignment is what + makes a forged value harmless. Loosening it here would turn that latent + mass-assignment into a live one, so the two cases stay separate: inside a + request the server decides, outside one the caller does, and outside a + request there is no client to decide against. + """ user = get_current_user() if user is None or user.is_anonymous: - self.created_by = None - self.updated_by = None - super().save(*args, **kwargs) - else: - # Check if the model is being created or updated - if self._state.adding: - # If created only set created_by value: set updated_by to None - self.created_by = user - self.updated_by = None - # If updated only set updated_by value don't touch created_by - self.updated_by = user + # No request, so nothing to derive from. Leave whatever the caller + # set, which is None for anyone who set nothing. super().save(*args, **kwargs) + return + if self._state.adding: + self.created_by = user + # On update `created_by` is left alone: it records who made the row, not + # who last touched it. + self.updated_by = user + super().save(*args, **kwargs) def __str__(self): return str(self.id) diff --git a/backend/common/custom_fields.py b/backend/common/custom_fields.py index 292ed565..ec9fc0e2 100644 --- a/backend/common/custom_fields.py +++ b/backend/common/custom_fields.py @@ -47,7 +47,9 @@ def validate_definition_options(field_type: str, options: Any) -> None: if not isinstance(options, list) or not options: raise drf_serializers.ValidationError( - {"options": "Dropdown fields require a non-empty list of {value, label} pairs"} + { + "options": "Dropdown fields require a non-empty list of {value, label} pairs" + } ) seen: set[str] = set() diff --git a/backend/common/external_auth.py b/backend/common/external_auth.py index 0091129a..1043df2b 100644 --- a/backend/common/external_auth.py +++ b/backend/common/external_auth.py @@ -57,7 +57,9 @@ class APIKeyAuthentication(BaseAuthentication): ).first() if not profile: - logger.error("No active admin profile found for org %s", organization.id) + logger.error( + "No active admin profile found for org %s", organization.id + ) raise AuthenticationFailed("Invalid API Key configuration") # Set org context on request for downstream use diff --git a/backend/common/management/commands/seed_data.py b/backend/common/management/commands/seed_data.py index fef3af5f..1bba3016 100644 --- a/backend/common/management/commands/seed_data.py +++ b/backend/common/management/commands/seed_data.py @@ -26,8 +26,15 @@ from faker import Faker from common.models import Org, Profile, Tags, Teams, User from common.rls import get_set_context_sql -from common.utils import (CASE_TYPE, CURRENCY_CODES, INDCHOICES, LEAD_SOURCE, - OPPORTUNITY_TYPES, PRIORITY_CHOICE, SOURCES) +from common.utils import ( + CASE_TYPE, + CURRENCY_CODES, + INDCHOICES, + LEAD_SOURCE, + OPPORTUNITY_TYPES, + PRIORITY_CHOICE, + SOURCES, +) from invoices.seed import InvoiceSeeder diff --git a/backend/common/middleware/get_company.py b/backend/common/middleware/get_company.py index 20ba5704..04c2289e 100644 --- a/backend/common/middleware/get_company.py +++ b/backend/common/middleware/get_company.py @@ -136,7 +136,10 @@ class GetProfileAndOrg: denial = scopes.check_request(pat.scopes, request.method, request.path) if denial is not None: logger.warning( - "PAT %s refused for %s %s", pat.token_prefix, request.method, request.path + "PAT %s refused for %s %s", + pat.token_prefix, + request.method, + request.path, ) return _denied(denial) @@ -179,7 +182,9 @@ class GetProfileAndOrg: ) request.profile = profile request.org = profile.org - logger.debug("Set org context from JWT: user=%s, org=%s", user_id, org_id) + logger.debug( + "Set org context from JWT: user=%s, org=%s", user_id, org_id + ) except Profile.DoesNotExist as exc: # User doesn't have access to this org anymore @@ -244,7 +249,9 @@ class GetProfileAndOrg: ).first() if not profile: - logger.error("No active admin profile found for org %s", organization.id) + logger.error( + "No active admin profile found for org %s", organization.id + ) # Let DRF authentication reject this, raising AuthenticationFailed # here escapes the middleware stack as a 500 instead of a 401. return None diff --git a/backend/common/migrations/0014_magic_link_token.py b/backend/common/migrations/0014_magic_link_token.py index 27b91a7d..4d950edb 100644 --- a/backend/common/migrations/0014_magic_link_token.py +++ b/backend/common/migrations/0014_magic_link_token.py @@ -1,11 +1,11 @@ # Generated by Django 5.2.9 on 2026-02-21 12:30 import uuid + from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0013_enable_rls_orders_salesforce"), ] diff --git a/backend/common/migrations/0015_activity_metadata_alter_activity_action.py b/backend/common/migrations/0015_activity_metadata_alter_activity_action.py index 96f09f0b..88b624af 100644 --- a/backend/common/migrations/0015_activity_metadata_alter_activity_action.py +++ b/backend/common/migrations/0015_activity_metadata_alter_activity_action.py @@ -4,20 +4,52 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0014_magic_link_token'), + ("common", "0014_magic_link_token"), ] operations = [ migrations.AddField( - model_name='activity', - name='metadata', + model_name="activity", + name="metadata", field=models.JSONField(blank=True, default=dict), ), migrations.AlterField( - model_name='activity', - name='action', - field=models.CharField(choices=[('CREATE', 'Created'), ('UPDATE', 'Updated'), ('DELETE', 'Deleted'), ('VIEW', 'Viewed'), ('COMMENT', 'Commented'), ('ASSIGN', 'Assigned'), ('STATUS_CHANGED', 'Status Changed'), ('PRIORITY_CHANGED', 'Priority Changed'), ('ROUTED', 'Routed'), ('ESCALATED', 'Escalated'), ('REOPENED', 'Reopened'), ('MERGED', 'Merged'), ('MERGE_TARGET', 'Merge Target'), ('LINKED_SOLUTION', 'Linked Solution'), ('UNLINKED_SOLUTION', 'Unlinked Solution'), ('WATCHED', 'Watched'), ('UNWATCHED', 'Unwatched'), ('MENTIONED', 'Mentioned'), ('APPROVAL_REQUESTED', 'Approval Requested'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected'), ('APPROVAL_CANCELLED', 'Approval Cancelled'), ('LINKED_ASSET', 'Linked Asset'), ('UNLINKED_ASSET', 'Unlinked Asset'), ('LINKED_JIRA', 'Linked Jira'), ('LINKED_PARENT', 'Linked Parent'), ('UNLINKED_PARENT', 'Unlinked Parent'), ('PARENT_CLOSED_CASCADE', 'Parent Closed Cascade'), ('TIME_LOGGED', 'Time Logged')], max_length=32), + model_name="activity", + name="action", + field=models.CharField( + choices=[ + ("CREATE", "Created"), + ("UPDATE", "Updated"), + ("DELETE", "Deleted"), + ("VIEW", "Viewed"), + ("COMMENT", "Commented"), + ("ASSIGN", "Assigned"), + ("STATUS_CHANGED", "Status Changed"), + ("PRIORITY_CHANGED", "Priority Changed"), + ("ROUTED", "Routed"), + ("ESCALATED", "Escalated"), + ("REOPENED", "Reopened"), + ("MERGED", "Merged"), + ("MERGE_TARGET", "Merge Target"), + ("LINKED_SOLUTION", "Linked Solution"), + ("UNLINKED_SOLUTION", "Unlinked Solution"), + ("WATCHED", "Watched"), + ("UNWATCHED", "Unwatched"), + ("MENTIONED", "Mentioned"), + ("APPROVAL_REQUESTED", "Approval Requested"), + ("APPROVED", "Approved"), + ("REJECTED", "Rejected"), + ("APPROVAL_CANCELLED", "Approval Cancelled"), + ("LINKED_ASSET", "Linked Asset"), + ("UNLINKED_ASSET", "Unlinked Asset"), + ("LINKED_JIRA", "Linked Jira"), + ("LINKED_PARENT", "Linked Parent"), + ("UNLINKED_PARENT", "Unlinked Parent"), + ("PARENT_CLOSED_CASCADE", "Parent Closed Cascade"), + ("TIME_LOGGED", "Time Logged"), + ], + max_length=32, + ), ), ] diff --git a/backend/common/migrations/0016_add_comment_is_internal.py b/backend/common/migrations/0016_add_comment_is_internal.py index 35122370..43a0cc48 100644 --- a/backend/common/migrations/0016_add_comment_is_internal.py +++ b/backend/common/migrations/0016_add_comment_is_internal.py @@ -4,20 +4,22 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0015_activity_metadata_alter_activity_action'), - ('contenttypes', '0002_remove_content_type_name'), + ("common", "0015_activity_metadata_alter_activity_action"), + ("contenttypes", "0002_remove_content_type_name"), ] operations = [ migrations.AddField( - model_name='comment', - name='is_internal', + model_name="comment", + name="is_internal", field=models.BooleanField(db_index=True, default=False), ), migrations.AddIndex( - model_name='comment', - index=models.Index(fields=['content_type', 'object_id', 'is_internal'], name='comment_content_7e7d4c_idx'), + model_name="comment", + index=models.Index( + fields=["content_type", "object_id", "is_internal"], + name="comment_content_7e7d4c_idx", + ), ), ] diff --git a/backend/common/migrations/0017_customfielddefinition.py b/backend/common/migrations/0017_customfielddefinition.py index f0a44ed0..53dafabd 100644 --- a/backend/common/migrations/0017_customfielddefinition.py +++ b/backend/common/migrations/0017_customfielddefinition.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-09 10:59 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -23,41 +24,127 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('common', '0016_add_comment_is_internal'), + ("common", "0016_add_comment_is_internal"), ] operations = [ migrations.CreateModel( - name='CustomFieldDefinition', + name="CustomFieldDefinition", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('target_model', models.CharField(choices=[('Case', 'Case'), ('Lead', 'Lead'), ('Contact', 'Contact'), ('Task', 'Task'), ('Opportunity', 'Opportunity')], max_length=32)), - ('key', models.SlugField(max_length=64)), - ('label', models.CharField(max_length=128)), - ('field_type', models.CharField(choices=[('text', 'Text'), ('textarea', 'Textarea'), ('number', 'Number'), ('dropdown', 'Dropdown'), ('date', 'Date'), ('checkbox', 'Checkbox')], max_length=16)), - ('options', models.JSONField(blank=True, help_text='List of {value, label} pairs for dropdown fields.', null=True)), - ('is_required', models.BooleanField(default=False)), - ('is_filterable', models.BooleanField(default=False)), - ('display_order', models.PositiveIntegerField(default=0)), - ('is_active', models.BooleanField(default=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_field_definitions', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "target_model", + models.CharField( + choices=[ + ("Case", "Case"), + ("Lead", "Lead"), + ("Contact", "Contact"), + ("Task", "Task"), + ("Opportunity", "Opportunity"), + ], + max_length=32, + ), + ), + ("key", models.SlugField(max_length=64)), + ("label", models.CharField(max_length=128)), + ( + "field_type", + models.CharField( + choices=[ + ("text", "Text"), + ("textarea", "Textarea"), + ("number", "Number"), + ("dropdown", "Dropdown"), + ("date", "Date"), + ("checkbox", "Checkbox"), + ], + max_length=16, + ), + ), + ( + "options", + models.JSONField( + blank=True, + help_text="List of {value, label} pairs for dropdown fields.", + null=True, + ), + ), + ("is_required", models.BooleanField(default=False)), + ("is_filterable", models.BooleanField(default=False)), + ("display_order", models.PositiveIntegerField(default=0)), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="custom_field_definitions", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Custom Field Definition', - 'verbose_name_plural': 'Custom Field Definitions', - 'db_table': 'custom_field_definition', - 'ordering': ('target_model', 'display_order', 'label'), - 'indexes': [models.Index(fields=['org', 'target_model', 'is_active'], name='custom_fiel_org_id_4b73e5_idx')], - 'constraints': [models.UniqueConstraint(fields=('org', 'target_model', 'key'), name='uniq_custom_field_per_org_target')], + "verbose_name": "Custom Field Definition", + "verbose_name_plural": "Custom Field Definitions", + "db_table": "custom_field_definition", + "ordering": ("target_model", "display_order", "label"), + "indexes": [ + models.Index( + fields=["org", "target_model", "is_active"], + name="custom_fiel_org_id_4b73e5_idx", + ) + ], + "constraints": [ + models.UniqueConstraint( + fields=("org", "target_model", "key"), + name="uniq_custom_field_per_org_target", + ) + ], }, ), migrations.RunPython(enable_rls, reverse_code=disable_rls), diff --git a/backend/common/migrations/0018_add_notification.py b/backend/common/migrations/0018_add_notification.py index 0605d418..43adc640 100644 --- a/backend/common/migrations/0018_add_notification.py +++ b/backend/common/migrations/0018_add_notification.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.5 on 2026-05-09 20:09 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -23,40 +24,115 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('common', '0017_customfielddefinition'), + ("common", "0017_customfielddefinition"), ] operations = [ migrations.CreateModel( - name='Notification', + name="Notification", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('verb', models.CharField(max_length=64)), - ('entity_type', models.CharField(blank=True, default='', max_length=50)), - ('entity_id', models.UUIDField(blank=True, null=True)), - ('entity_name', models.CharField(blank=True, default='', max_length=255)), - ('data', models.JSONField(blank=True, default=dict)), - ('link', models.CharField(blank=True, default='', max_length=500)), - ('read_at', models.DateTimeField(blank=True, null=True)), - ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dispatched_notifications', to='common.profile')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='common.org')), - ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='common.profile')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("verb", models.CharField(max_length=64)), + ( + "entity_type", + models.CharField(blank=True, default="", max_length=50), + ), + ("entity_id", models.UUIDField(blank=True, null=True)), + ( + "entity_name", + models.CharField(blank=True, default="", max_length=255), + ), + ("data", models.JSONField(blank=True, default=dict)), + ("link", models.CharField(blank=True, default="", max_length=500)), + ("read_at", models.DateTimeField(blank=True, null=True)), + ( + "actor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="dispatched_notifications", + to="common.profile", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="common.org", + ), + ), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="common.profile", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Notification', - 'verbose_name_plural': 'Notifications', - 'db_table': 'notification', - 'ordering': ('-created_at',), - 'indexes': [models.Index(fields=['org', 'recipient', '-created_at'], name='notificatio_org_id_d4a5d9_idx'), models.Index(fields=['recipient', 'read_at'], name='notificatio_recipie_2d4243_idx')], + "verbose_name": "Notification", + "verbose_name_plural": "Notifications", + "db_table": "notification", + "ordering": ("-created_at",), + "indexes": [ + models.Index( + fields=["org", "recipient", "-created_at"], + name="notificatio_org_id_d4a5d9_idx", + ), + models.Index( + fields=["recipient", "read_at"], + name="notificatio_recipie_2d4243_idx", + ), + ], }, ), migrations.RunPython(enable_rls, reverse_code=disable_rls), diff --git a/backend/common/migrations/0019_activity_analytics_idx.py b/backend/common/migrations/0019_activity_analytics_idx.py index 59da5423..4f71a00d 100644 --- a/backend/common/migrations/0019_activity_analytics_idx.py +++ b/backend/common/migrations/0019_activity_analytics_idx.py @@ -7,7 +7,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0018_add_notification"), ] diff --git a/backend/common/migrations/0020_org_csat_enabled.py b/backend/common/migrations/0020_org_csat_enabled.py index 3714e162..552cda17 100644 --- a/backend/common/migrations/0020_org_csat_enabled.py +++ b/backend/common/migrations/0020_org_csat_enabled.py @@ -4,15 +4,14 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0019_activity_analytics_idx'), + ("common", "0019_activity_analytics_idx"), ] operations = [ migrations.AddField( - model_name='org', - name='csat_enabled', + model_name="org", + name="csat_enabled", field=models.BooleanField(default=True), ), ] diff --git a/backend/common/migrations/0021_org_auto_close_children.py b/backend/common/migrations/0021_org_auto_close_children.py index 9305a8aa..51aec6cb 100644 --- a/backend/common/migrations/0021_org_auto_close_children.py +++ b/backend/common/migrations/0021_org_auto_close_children.py @@ -2,7 +2,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0020_org_csat_enabled"), ] diff --git a/backend/common/migrations/0022_alter_activity_action.py b/backend/common/migrations/0022_alter_activity_action.py index 840911ca..3afe376c 100644 --- a/backend/common/migrations/0022_alter_activity_action.py +++ b/backend/common/migrations/0022_alter_activity_action.py @@ -4,15 +4,49 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0021_org_auto_close_children'), + ("common", "0021_org_auto_close_children"), ] operations = [ migrations.AlterField( - model_name='activity', - name='action', - field=models.CharField(choices=[('CREATE', 'Created'), ('UPDATE', 'Updated'), ('DELETE', 'Deleted'), ('VIEW', 'Viewed'), ('COMMENT', 'Commented'), ('ASSIGN', 'Assigned'), ('STATUS_CHANGED', 'Status Changed'), ('PRIORITY_CHANGED', 'Priority Changed'), ('ROUTED', 'Routed'), ('ESCALATED', 'Escalated'), ('REOPENED', 'Reopened'), ('MERGED', 'Merged'), ('MERGE_TARGET', 'Merge Target'), ('UNMERGED', 'Unmerged'), ('UNMERGE_TARGET', 'Unmerge Target'), ('LINKED_SOLUTION', 'Linked Solution'), ('UNLINKED_SOLUTION', 'Unlinked Solution'), ('WATCHED', 'Watched'), ('UNWATCHED', 'Unwatched'), ('MENTIONED', 'Mentioned'), ('APPROVAL_REQUESTED', 'Approval Requested'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected'), ('APPROVAL_CANCELLED', 'Approval Cancelled'), ('LINKED_ASSET', 'Linked Asset'), ('UNLINKED_ASSET', 'Unlinked Asset'), ('LINKED_JIRA', 'Linked Jira'), ('LINKED_PARENT', 'Linked Parent'), ('UNLINKED_PARENT', 'Unlinked Parent'), ('PARENT_CLOSED_CASCADE', 'Parent Closed Cascade'), ('TIME_LOGGED', 'Time Logged')], max_length=32), + model_name="activity", + name="action", + field=models.CharField( + choices=[ + ("CREATE", "Created"), + ("UPDATE", "Updated"), + ("DELETE", "Deleted"), + ("VIEW", "Viewed"), + ("COMMENT", "Commented"), + ("ASSIGN", "Assigned"), + ("STATUS_CHANGED", "Status Changed"), + ("PRIORITY_CHANGED", "Priority Changed"), + ("ROUTED", "Routed"), + ("ESCALATED", "Escalated"), + ("REOPENED", "Reopened"), + ("MERGED", "Merged"), + ("MERGE_TARGET", "Merge Target"), + ("UNMERGED", "Unmerged"), + ("UNMERGE_TARGET", "Unmerge Target"), + ("LINKED_SOLUTION", "Linked Solution"), + ("UNLINKED_SOLUTION", "Unlinked Solution"), + ("WATCHED", "Watched"), + ("UNWATCHED", "Unwatched"), + ("MENTIONED", "Mentioned"), + ("APPROVAL_REQUESTED", "Approval Requested"), + ("APPROVED", "Approved"), + ("REJECTED", "Rejected"), + ("APPROVAL_CANCELLED", "Approval Cancelled"), + ("LINKED_ASSET", "Linked Asset"), + ("UNLINKED_ASSET", "Unlinked Asset"), + ("LINKED_JIRA", "Linked Jira"), + ("LINKED_PARENT", "Linked Parent"), + ("UNLINKED_PARENT", "Unlinked Parent"), + ("PARENT_CLOSED_CASCADE", "Parent Closed Cascade"), + ("TIME_LOGGED", "Time Logged"), + ], + max_length=32, + ), ), ] diff --git a/backend/common/migrations/0023_alter_customfielddefinition_target_model.py b/backend/common/migrations/0023_alter_customfielddefinition_target_model.py index 2479b715..a9fb8549 100644 --- a/backend/common/migrations/0023_alter_customfielddefinition_target_model.py +++ b/backend/common/migrations/0023_alter_customfielddefinition_target_model.py @@ -4,15 +4,25 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0022_alter_activity_action'), + ("common", "0022_alter_activity_action"), ] operations = [ migrations.AlterField( - model_name='customfielddefinition', - name='target_model', - field=models.CharField(choices=[('Account', 'Account'), ('Case', 'Case'), ('Contact', 'Contact'), ('Invoice', 'Invoice'), ('Lead', 'Lead'), ('Opportunity', 'Opportunity'), ('Task', 'Task')], max_length=32), + model_name="customfielddefinition", + name="target_model", + field=models.CharField( + choices=[ + ("Account", "Account"), + ("Case", "Case"), + ("Contact", "Contact"), + ("Invoice", "Invoice"), + ("Lead", "Lead"), + ("Opportunity", "Opportunity"), + ("Task", "Task"), + ], + max_length=32, + ), ), ] diff --git a/backend/common/migrations/0024_alter_customfielddefinition_target_model.py b/backend/common/migrations/0024_alter_customfielddefinition_target_model.py index 5c592b3e..a729a59d 100644 --- a/backend/common/migrations/0024_alter_customfielddefinition_target_model.py +++ b/backend/common/migrations/0024_alter_customfielddefinition_target_model.py @@ -4,15 +4,27 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0023_alter_customfielddefinition_target_model'), + ("common", "0023_alter_customfielddefinition_target_model"), ] operations = [ migrations.AlterField( - model_name='customfielddefinition', - name='target_model', - field=models.CharField(choices=[('Account', 'Account'), ('Case', 'Case'), ('Contact', 'Contact'), ('Estimate', 'Estimate'), ('Invoice', 'Invoice'), ('Lead', 'Lead'), ('Opportunity', 'Opportunity'), ('RecurringInvoice', 'Recurring Invoice'), ('Task', 'Task')], max_length=32), + model_name="customfielddefinition", + name="target_model", + field=models.CharField( + choices=[ + ("Account", "Account"), + ("Case", "Case"), + ("Contact", "Contact"), + ("Estimate", "Estimate"), + ("Invoice", "Invoice"), + ("Lead", "Lead"), + ("Opportunity", "Opportunity"), + ("RecurringInvoice", "Recurring Invoice"), + ("Task", "Task"), + ], + max_length=32, + ), ), ] diff --git a/backend/common/migrations/0025_magic_link_token_otp.py b/backend/common/migrations/0025_magic_link_token_otp.py index e0867735..e9b00d16 100644 --- a/backend/common/migrations/0025_magic_link_token_otp.py +++ b/backend/common/migrations/0025_magic_link_token_otp.py @@ -2,7 +2,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0024_alter_customfielddefinition_target_model"), ] diff --git a/backend/common/migrations/0026_add_user_name.py b/backend/common/migrations/0026_add_user_name.py index a67be817..33b39084 100644 --- a/backend/common/migrations/0026_add_user_name.py +++ b/backend/common/migrations/0026_add_user_name.py @@ -4,15 +4,16 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0025_magic_link_token_otp'), + ("common", "0025_magic_link_token_otp"), ] operations = [ migrations.AddField( - model_name='user', - name='name', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='name'), + model_name="user", + name="name", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="name" + ), ), ] diff --git a/backend/common/migrations/0027_personalaccesstoken.py b/backend/common/migrations/0027_personalaccesstoken.py index 011f38fc..78281783 100644 --- a/backend/common/migrations/0027_personalaccesstoken.py +++ b/backend/common/migrations/0027_personalaccesstoken.py @@ -1,39 +1,100 @@ # Generated by Django 6.0.5 on 2026-06-01 03:47 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0026_add_user_name'), + ("common", "0026_add_user_name"), ] operations = [ migrations.CreateModel( - name='PersonalAccessToken', + name="PersonalAccessToken", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255)), - ('token_hash', models.CharField(db_index=True, max_length=64, unique=True)), - ('token_prefix', models.CharField(max_length=20)), - ('scopes', models.JSONField(blank=True, default=list)), - ('expires_at', models.DateTimeField(blank=True, null=True)), - ('last_used_at', models.DateTimeField(blank=True, null=True)), - ('revoked_at', models.DateTimeField(blank=True, null=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(help_text='Organization this record belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='common.org')), - ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='common.profile')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "token_hash", + models.CharField(db_index=True, max_length=64, unique=True), + ), + ("token_prefix", models.CharField(max_length=20)), + ("scopes", models.JSONField(blank=True, default=list)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ("last_used_at", models.DateTimeField(blank=True, null=True)), + ("revoked_at", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + help_text="Organization this record belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="common.org", + ), + ), + ( + "profile", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="access_tokens", + to="common.profile", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'db_table': 'personal_access_token', - 'indexes': [models.Index(fields=['org', '-created_at'], name='personal_ac_org_id_cc57f9_idx')], + "db_table": "personal_access_token", + "indexes": [ + models.Index( + fields=["org", "-created_at"], + name="personal_ac_org_id_cc57f9_idx", + ) + ], }, ), ] diff --git a/backend/common/migrations/0029_delete_sessiontoken.py b/backend/common/migrations/0029_delete_sessiontoken.py index 4a8a34e5..431be91a 100644 --- a/backend/common/migrations/0029_delete_sessiontoken.py +++ b/backend/common/migrations/0029_delete_sessiontoken.py @@ -15,25 +15,24 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('common', '0028_restamp_rls_policies'), + ("common", "0028_restamp_rls_policies"), ] operations = [ migrations.RemoveIndex( - model_name='sessiontoken', - name='session_tok_user_id_ce3d01_idx', + model_name="sessiontoken", + name="session_tok_user_id_ce3d01_idx", ), migrations.RemoveIndex( - model_name='sessiontoken', - name='session_tok_token_j_a0ab6c_idx', + model_name="sessiontoken", + name="session_tok_token_j_a0ab6c_idx", ), migrations.RemoveIndex( - model_name='sessiontoken', - name='session_tok_expires_08e0bc_idx', + model_name="sessiontoken", + name="session_tok_expires_08e0bc_idx", ), migrations.DeleteModel( - name='SessionToken', + name="SessionToken", ), ] diff --git a/backend/common/migrations/0030_portalaccesstoken.py b/backend/common/migrations/0030_portalaccesstoken.py index 51bdc7b8..250600a1 100644 --- a/backend/common/migrations/0030_portalaccesstoken.py +++ b/backend/common/migrations/0030_portalaccesstoken.py @@ -1,30 +1,60 @@ # Generated by Django 6.0.7 on 2026-07-30 18:40 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0029_delete_sessiontoken'), + ("common", "0029_delete_sessiontoken"), ] operations = [ migrations.CreateModel( - name='PortalAccessToken', + name="PortalAccessToken", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), - ('token_hash', models.CharField(db_index=True, max_length=64, unique=True)), - ('resource_type', models.CharField(choices=[('invoice', 'Invoice'), ('estimate', 'Estimate'), ('csat', 'CSAT survey')], max_length=16)), - ('resource_id', models.UUIDField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='portal_access_tokens', to='common.org')), + ( + "id", + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ( + "token_hash", + models.CharField(db_index=True, max_length=64, unique=True), + ), + ( + "resource_type", + models.CharField( + choices=[ + ("invoice", "Invoice"), + ("estimate", "Estimate"), + ("csat", "CSAT survey"), + ], + max_length=16, + ), + ), + ("resource_id", models.UUIDField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="portal_access_tokens", + to="common.org", + ), + ), ], options={ - 'db_table': 'portal_access_token', - 'indexes': [models.Index(fields=['resource_type', 'resource_id'], name='portal_acce_resourc_e9c0d2_idx')], + "db_table": "portal_access_token", + "indexes": [ + models.Index( + fields=["resource_type", "resource_id"], + name="portal_acce_resourc_e9c0d2_idx", + ) + ], }, ), ] diff --git a/backend/common/migrations/0031_backfill_portal_tokens.py b/backend/common/migrations/0031_backfill_portal_tokens.py index 984f091e..61e660ef 100644 --- a/backend/common/migrations/0031_backfill_portal_tokens.py +++ b/backend/common/migrations/0031_backfill_portal_tokens.py @@ -97,7 +97,6 @@ def backfill(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("common", "0030_portalaccesstoken"), ( diff --git a/backend/common/migrations/0032_vertical_packs.py b/backend/common/migrations/0032_vertical_packs.py index b11bf6a7..6063d662 100644 --- a/backend/common/migrations/0032_vertical_packs.py +++ b/backend/common/migrations/0032_vertical_packs.py @@ -1,7 +1,8 @@ # Generated by Django 6.0.7 on 2026-08-01 11:20 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import connection, migrations, models @@ -23,45 +24,119 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - # RLS policy creation can't run inside an atomic block. atomic = False dependencies = [ - ('common', '0031_backfill_portal_tokens'), + ("common", "0031_backfill_portal_tokens"), ] operations = [ migrations.AddField( - model_name='org', - name='terminology', - field=models.JSONField(blank=True, default=dict, help_text="Label overrides, e.g. {'lead.plural': 'Enquiries'}."), + model_name="org", + name="terminology", + field=models.JSONField( + blank=True, + default=dict, + help_text="Label overrides, e.g. {'lead.plural': 'Enquiries'}.", + ), ), migrations.AddField( - model_name='org', - name='vertical', - field=models.CharField(blank=True, default='', help_text="Pack id applied to this org, e.g. 'real-estate'. Descriptive only.", max_length=64), + model_name="org", + name="vertical", + field=models.CharField( + blank=True, + default="", + help_text="Pack id applied to this org, e.g. 'real-estate'. Descriptive only.", + max_length=64, + ), ), migrations.CreateModel( - name='PackApplication', + name="PackApplication", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('pack_id', models.CharField(max_length=64)), - ('pack_version', models.PositiveIntegerField()), - ('result', models.JSONField(default=dict, help_text="{'created': [...], 'skipped': [...], 'failed': [...]}")), - ('applied_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pack_applications', to='common.profile')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(help_text='Organization this record belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_set', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("pack_id", models.CharField(max_length=64)), + ("pack_version", models.PositiveIntegerField()), + ( + "result", + models.JSONField( + default=dict, + help_text="{'created': [...], 'skipped': [...], 'failed': [...]}", + ), + ), + ( + "applied_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="pack_applications", + to="common.profile", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + help_text="Organization this record belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="%(class)s_set", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Pack Application', - 'verbose_name_plural': 'Pack Applications', - 'db_table': 'pack_application', - 'ordering': ('-created_at',), - 'indexes': [models.Index(fields=['org', '-created_at'], name='pack_applic_org_id_78eec5_idx')], + "verbose_name": "Pack Application", + "verbose_name_plural": "Pack Applications", + "db_table": "pack_application", + "ordering": ("-created_at",), + "indexes": [ + models.Index( + fields=["org", "-created_at"], + name="pack_applic_org_id_78eec5_idx", + ) + ], }, ), migrations.RunPython(enable_rls, reverse_code=disable_rls), diff --git a/backend/common/migrations/0033_alter_securityauditlog_event_type.py b/backend/common/migrations/0033_alter_securityauditlog_event_type.py index fe5648d5..f137c11a 100644 --- a/backend/common/migrations/0033_alter_securityauditlog_event_type.py +++ b/backend/common/migrations/0033_alter_securityauditlog_event_type.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("common", "0032_vertical_packs"), ] diff --git a/backend/common/migrations/0037_org_timezone.py b/backend/common/migrations/0037_org_timezone.py index ee19c931..69d4f6b1 100644 --- a/backend/common/migrations/0037_org_timezone.py +++ b/backend/common/migrations/0037_org_timezone.py @@ -1,6 +1,7 @@ -import common.validators from django.db import migrations, models +import common.validators + # The value of `settings.TIME_ZONE` immediately before orgs carried their own, # captured here rather than read from settings on purpose: this change also # moves that setting to "UTC", so by the time the migration runs the old value @@ -24,7 +25,6 @@ def back_to_the_field_default(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ ("common", "0036_unscope_security_audit_log"), ] @@ -40,7 +40,5 @@ class Migration(migrations.Migration): validators=[common.validators.validate_iana_timezone], ), ), - migrations.RunPython( - adopt_the_deployment_timezone, back_to_the_field_default - ), + migrations.RunPython(adopt_the_deployment_timezone, back_to_the_field_default), ] diff --git a/backend/common/models.py b/backend/common/models.py index 5da60d01..edb79d9e 100644 --- a/backend/common/models.py +++ b/backend/common/models.py @@ -13,8 +13,8 @@ from django.utils import timezone from django.utils.text import slugify from django.utils.timesince import timesince from django.utils.translation import gettext_lazy as _ + from common.base import BaseModel, BaseOrgModel -from common.validators import validate_iana_timezone from common.utils import ( COUNTRIES, CURRENCY_CODES, @@ -28,6 +28,7 @@ from common.utils import ( is_document_file_video, is_document_file_zip, ) +from common.validators import validate_iana_timezone from .manager import UserManager diff --git a/backend/common/packs/applier.py b/backend/common/packs/applier.py index 673e80cf..16a8d717 100644 --- a/backend/common/packs/applier.py +++ b/backend/common/packs/applier.py @@ -41,7 +41,9 @@ class _Report: def add_created(self, type_: str, name: str) -> None: self.created.append({"type": type_, "name": name}) - def add_skipped(self, type_: str, name: str, reason: str = "already exists") -> None: + def add_skipped( + self, type_: str, name: str, reason: str = "already exists" + ) -> None: self.skipped.append({"type": type_, "name": name, "reason": reason}) def as_dict(self) -> dict: @@ -269,7 +271,9 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None: # real record (or renaming another tag onto this slug) is harmless, # nothing destructive keys off it. sample_tag, _ = Tags.objects.get_or_create( - org=org, slug=SAMPLE_TAG_SLUG, defaults={"name": SAMPLE_TAG_NAME, "color": "gray"} + org=org, + slug=SAMPLE_TAG_SLUG, + defaults={"name": SAMPLE_TAG_NAME, "color": "gray"}, ) def finish(obj): @@ -298,7 +302,9 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None: for spec in sample.get("accounts") or []: obj = _create_sample( - report, "sample_account", spec["name"], + report, + "sample_account", + spec["name"], lambda s=spec: Account.objects.create( org=org, name=s["name"], @@ -320,7 +326,9 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None: for spec in sample.get("contacts") or []: name = f"{spec.get('first_name', '')} {spec.get('last_name', '')}".strip() obj = _create_sample( - report, "sample_contact", name, + report, + "sample_contact", + name, lambda s=spec: Contact.objects.create( org=org, first_name=s["first_name"], @@ -350,7 +358,9 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None: for spec in sample.get("deals") or []: obj = _create_sample( - report, "sample_deal", spec["name"], + report, + "sample_deal", + spec["name"], lambda s=spec: Opportunity.objects.create( org=org, name=s["name"], @@ -373,7 +383,9 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None: case_stages = {s.name: s for s in CaseStage.objects.filter(org=org)} for spec in sample.get("tickets") or []: obj = _create_sample( - report, "sample_ticket", spec["name"], + report, + "sample_ticket", + spec["name"], lambda s=spec: Case.objects.create( org=org, name=s["name"], @@ -399,7 +411,9 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None: leads: dict[str, Lead] = {} for spec in sample.get("leads") or []: obj = _create_sample( - report, "sample_lead", spec["title"], + report, + "sample_lead", + spec["title"], lambda s=spec: Lead.objects.create( org=org, title=s["title"], @@ -425,7 +439,9 @@ def _apply_sample_data(org, pack: dict, actor, report: _Report) -> None: task_stages = {s.name: s for s in TaskStage.objects.filter(org=org)} for spec in sample.get("tasks") or []: obj = _create_sample( - report, "sample_task", spec["title"], + report, + "sample_task", + spec["title"], lambda s=spec: Task.objects.create( org=org, title=s["title"], @@ -584,7 +600,7 @@ def apply_pack(org, pack: dict, actor) -> dict: # actor must belong to org. _apply_sample_data does # lead.assigned_to.add(actor) with no further org check of its own, so # without this guard a caller that passes a mismatched (org, actor) pair - #; e.g. an org-B apply given an org-A profile: creates cross-org + # ; e.g. an org-B apply given an org-A profile: creates cross-org # assignments: an org-A profile ends up assigned to org-B's leads, and # that profile can then read those leads through an ordinary "my leads" # query, which is a tenant-isolation break. This is enforced here, not @@ -617,9 +633,7 @@ def apply_pack(org, pack: dict, actor) -> dict: if connection.vendor == "postgresql": with connection.cursor() as cursor: - cursor.execute( - "SELECT pg_advisory_xact_lock(hashtext(%s))", [str(org.id)] - ) + cursor.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", [str(org.id)]) report = _Report() _apply_pipelines(org, pack, report) diff --git a/backend/common/packs/schema.py b/backend/common/packs/schema.py index 9100de9e..fbdf28d2 100644 --- a/backend/common/packs/schema.py +++ b/backend/common/packs/schema.py @@ -11,9 +11,18 @@ from decimal import Decimal, InvalidOperation from common.custom_fields import SUPPORTED_TARGETS, validate_definition_options TOP_LEVEL_KEYS = { - "id", "version", "name", "description", "terminology", - "lead_pipeline", "case_pipeline", "task_pipeline", - "custom_fields", "tags", "products", "sample_data", + "id", + "version", + "name", + "description", + "terminology", + "lead_pipeline", + "case_pipeline", + "task_pipeline", + "custom_fields", + "tags", + "products", + "sample_data", } # is_default is a recognised key on both, not a typo, but is rejected by @@ -21,15 +30,26 @@ TOP_LEVEL_KEYS = { # genuinely unknown keys. See _reject_is_default for why this matters. PIPELINE_KEYS = {"name", "description", "stages", "is_default"} STAGE_KEYS = { - "name", "order", "color", "stage_type", "maps_to_status", "wip_limit", + "name", + "order", + "color", + "stage_type", + "maps_to_status", + "wip_limit", "is_default", } # win_probability exists on LeadStage only (leads/models.py:296). LEAD_STAGE_KEYS = STAGE_KEYS | {"win_probability"} CUSTOM_FIELD_KEYS = { - "target", "key", "label", "field_type", "options", - "is_required", "is_filterable", "display_order", + "target", + "key", + "label", + "field_type", + "options", + "is_required", + "is_filterable", + "display_order", } FIELD_TYPES = {"text", "textarea", "number", "dropdown", "date", "checkbox"} @@ -37,33 +57,82 @@ TAG_KEYS = {"name", "color", "description"} PRODUCT_KEYS = {"name", "sku", "price", "currency", "category", "description"} SAMPLE_LEAD_KEYS = { - "title", "first_name", "last_name", "email", "phone", - "source", "status", "stage", "custom_fields", - "opportunity_amount", "currency", + "title", + "first_name", + "last_name", + "email", + "phone", + "source", + "status", + "stage", + "custom_fields", + "opportunity_amount", + "currency", } SAMPLE_ACCOUNT_KEYS = { - "name", "email", "phone", "website", "industry", "city", "country", - "description", "custom_fields", + "name", + "email", + "phone", + "website", + "industry", + "city", + "country", + "description", + "custom_fields", } SAMPLE_CONTACT_KEYS = { - "first_name", "last_name", "email", "phone", "title", "department", - "organization", "account", "city", "country", "description", "custom_fields", + "first_name", + "last_name", + "email", + "phone", + "title", + "department", + "organization", + "account", + "city", + "country", + "description", + "custom_fields", } SAMPLE_DEAL_KEYS = { - "name", "account", "stage", "amount", "currency", "probability", - "closed_on", "lead_source", "description", "custom_fields", + "name", + "account", + "stage", + "amount", + "currency", + "probability", + "closed_on", + "lead_source", + "description", + "custom_fields", } SAMPLE_TICKET_KEYS = { - "name", "status", "priority", "case_type", "account", "contacts", - "stage", "description", "custom_fields", + "name", + "status", + "priority", + "case_type", + "account", + "contacts", + "stage", + "description", + "custom_fields", } SAMPLE_TASK_KEYS = { - "title", "status", "priority", "due_date", "description", "stage", - "account", "deal", "ticket", "lead", "custom_fields", + "title", + "status", + "priority", + "due_date", + "description", + "stage", + "account", + "deal", + "ticket", + "lead", + "custom_fields", } # Every entity `sample_data` understands, in the order the applier creates them -#. A later entity may reference an earlier one by name, never the reverse. +# . A later entity may reference an earlier one by name, never the reverse. # Tasks are last because a task is the only row that can point at any of the # other five. common.packs.applier imports this rather than restating it. SAMPLE_ENTITY_KEYS = ("accounts", "contacts", "deals", "tickets", "leads", "tasks") @@ -200,7 +269,9 @@ def _validate_custom_field(where: str, field: dict) -> None: _reject_unknown(where, field, CUSTOM_FIELD_KEYS) target = field.get("target") if target not in SUPPORTED_TARGETS: - raise PackValidationError(f"{where}: target {target!r} is not a supported entity") + raise PackValidationError( + f"{where}: target {target!r} is not a supported entity" + ) if not field.get("key"): raise PackValidationError(f"{where}: key is required") if not field.get("label"): @@ -298,7 +369,9 @@ def _reference_index(where: str, rows: list, key_of, label: str) -> set[str]: return seen -def _check_reference(where: str, value, field: str, known: set[str], target: str) -> None: +def _check_reference( + where: str, value, field: str, known: set[str], target: str +) -> None: if value is None: return if value not in known: @@ -383,28 +456,42 @@ def _validate_sample_data(where: str, sample: dict, raw: dict) -> None: _reject_unknown(at, row, SAMPLE_CONTACT_KEYS) _require(at, row, "first_name", "last_name") _validate_choice(at, row.get("country"), "country", countries) - _check_reference(at, row.get("account"), "account", known_accounts, "sample account") + _check_reference( + at, row.get("account"), "account", known_accounts, "sample account" + ) for i, row in enumerate(deals): at = f"{where}.deals[{i}]" _reject_unknown(at, row, SAMPLE_DEAL_KEYS) _require(at, row, "name") _validate_choice(at, row.get("stage"), "stage", {c for c, _l in STAGES}) - _validate_choice(at, row.get("lead_source"), "lead_source", {c for c, _l in SOURCES}) + _validate_choice( + at, row.get("lead_source"), "lead_source", {c for c, _l in SOURCES} + ) _validate_choice(at, row.get("currency"), "currency", currencies) _validate_date(at, row.get("closed_on"), "closed_on") - _check_reference(at, row.get("account"), "account", known_accounts, "sample account") + _check_reference( + at, row.get("account"), "account", known_accounts, "sample account" + ) for i, row in enumerate(tickets): at = f"{where}.tickets[{i}]" _reject_unknown(at, row, SAMPLE_TICKET_KEYS) # status and priority have no model default and are not nullable. _require(at, row, "name", "status", "priority") - _validate_choice(at, row.get("status"), "status", {c for c, _l in STATUS_CHOICE}) - _validate_choice(at, row.get("priority"), "priority", {c for c, _l in PRIORITY_CHOICE}) - _validate_choice(at, row.get("case_type"), "case_type", {c for c, _l in CASE_TYPE}) + _validate_choice( + at, row.get("status"), "status", {c for c, _l in STATUS_CHOICE} + ) + _validate_choice( + at, row.get("priority"), "priority", {c for c, _l in PRIORITY_CHOICE} + ) + _validate_choice( + at, row.get("case_type"), "case_type", {c for c, _l in CASE_TYPE} + ) _validate_choice(at, row.get("stage"), "stage", case_stages) - _check_reference(at, row.get("account"), "account", known_accounts, "sample account") + _check_reference( + at, row.get("account"), "account", known_accounts, "sample account" + ) for name in row.get("contacts") or []: _check_reference(at, name, "contacts", known_contacts, "sample contact") @@ -412,7 +499,9 @@ def _validate_sample_data(where: str, sample: dict, raw: dict) -> None: at = f"{where}.tasks[{i}]" _reject_unknown(at, row, SAMPLE_TASK_KEYS) _require(at, row, "title", "status", "priority") - _validate_choice(at, row.get("status"), "status", {c for c, _l in Task.STATUS_CHOICES}) + _validate_choice( + at, row.get("status"), "status", {c for c, _l in Task.STATUS_CHOICES} + ) _validate_choice( at, row.get("priority"), "priority", {c for c, _l in Task.PRIORITY_CHOICES} ) @@ -424,9 +513,13 @@ def _validate_sample_data(where: str, sample: dict, raw: dict) -> None: f"{at}: a task may link to at most one parent, got {parents} " "(tasks.Task.clean rejects more, and Task.save runs full_clean)" ) - _check_reference(at, row.get("account"), "account", known_accounts, "sample account") + _check_reference( + at, row.get("account"), "account", known_accounts, "sample account" + ) _check_reference(at, row.get("deal"), "deal", known_deals, "sample deal") - _check_reference(at, row.get("ticket"), "ticket", known_tickets, "sample ticket") + _check_reference( + at, row.get("ticket"), "ticket", known_tickets, "sample ticket" + ) _check_reference(at, row.get("lead"), "lead", known_leads, "sample lead") for i, row in enumerate(leads): diff --git a/backend/common/pat_auth.py b/backend/common/pat_auth.py index 5ae15f58..0ffde08f 100644 --- a/backend/common/pat_auth.py +++ b/backend/common/pat_auth.py @@ -16,7 +16,7 @@ def _extract_raw(request): """Pull a bcrm_pat_ token from Authorization: Bearer or Token header.""" auth = request.headers.get("Authorization", "") if auth.startswith("Bearer "): - candidate = auth[len("Bearer "):].strip() + candidate = auth[len("Bearer ") :].strip() if candidate.startswith(PAT_PREFIX): return candidate token = request.headers.get("Token", "") diff --git a/backend/common/scopes.py b/backend/common/scopes.py index fe967137..ec790773 100644 --- a/backend/common/scopes.py +++ b/backend/common/scopes.py @@ -113,16 +113,12 @@ def normalize_scope(raw): raise ValueError("A scope cannot be empty.") parts = candidate.split(":") if len(parts) != 2: - raise ValueError( - f"Scope {raw!r} is not in : form." - ) + raise ValueError(f"Scope {raw!r} is not in : form.") resource, action = parts if resource != WILDCARD and resource not in API_RESOURCES: raise ValueError(f"Unknown scope resource {resource!r}.") if action not in SCOPE_ACTIONS: - raise ValueError( - f"Unknown scope action {action!r}. Use 'read' or 'write'." - ) + raise ValueError(f"Unknown scope action {action!r}. Use 'read' or 'write'.") return f"{resource}:{action}" diff --git a/backend/common/search_views.py b/backend/common/search_views.py index 17dcb5d4..75b2ec82 100644 --- a/backend/common/search_views.py +++ b/backend/common/search_views.py @@ -79,9 +79,7 @@ class GlobalSearchView(APIView): results = [] # Leads - leads = _scope_superuser( - Lead.objects.filter(org=org), profile, user - ).filter( + leads = _scope_superuser(Lead.objects.filter(org=org), profile, user).filter( Q(title__icontains=q) | Q(first_name__icontains=q) | Q(last_name__icontains=q) @@ -139,9 +137,7 @@ class GlobalSearchView(APIView): ) # Contacts - contacts = _scope_orgadmin( - Contact.objects.filter(org=org), profile - ).filter( + contacts = _scope_orgadmin(Contact.objects.filter(org=org), profile).filter( Q(first_name__icontains=q) | Q(last_name__icontains=q) | Q(email__icontains=q) @@ -196,7 +192,9 @@ class GlobalSearchView(APIView): { "type": "invoice", "id": str(invoice.id), - "title": invoice.invoice_number or invoice.invoice_title or "Invoice", + "title": invoice.invoice_number + or invoice.invoice_title + or "Invoice", "subtitle": invoice.client_name or (invoice.account.name if invoice.account_id else "") or invoice.status diff --git a/backend/common/status.py b/backend/common/status.py index 50c6fd7c..3e9973ff 100644 --- a/backend/common/status.py +++ b/backend/common/status.py @@ -8,7 +8,6 @@ And RFC 4918 - https://tools.ietf.org/html/rfc4918 from __future__ import unicode_literals - HTTP_100_CONTINUE = 100 HTTP_101_SWITCHING_PROTOCOLS = 101 HTTP_200_OK = 200 diff --git a/backend/common/tasks.py b/backend/common/tasks.py index 583e4498..2a00b87a 100644 --- a/backend/common/tasks.py +++ b/backend/common/tasks.py @@ -4,9 +4,9 @@ from datetime import timedelta from botocore.exceptions import ClientError from celery import shared_task from django.conf import settings +from django.core.exceptions import ValidationError from django.core.mail import EmailMessage from django.core.validators import validate_email -from django.core.exceptions import ValidationError from django.db import connection from django.template.loader import render_to_string from django.utils import timezone @@ -106,7 +106,9 @@ def send_magic_link_email(token_id, raw_code=None): try: validate_email(email) except ValidationError: - logger.warning("Magic link skipped: invalid email format for token %s", token_id) + logger.warning( + "Magic link skipped: invalid email format for token %s", token_id + ) return if magic_token.delivery == "code": @@ -122,7 +124,9 @@ def send_magic_link_email(token_id, raw_code=None): {"code": raw_code}, ) else: - magic_link_url = f"{settings.FRONTEND_URL}/login/verify?token={magic_token.token}" + magic_link_url = ( + f"{settings.FRONTEND_URL}/login/verify?token={magic_token.token}" + ) subject = "Your BottleCRM sign-in link" html_content = render_to_string( "magic_link_email.html", @@ -372,9 +376,7 @@ def purge_read_notifications(days=NOTIFICATION_PURGE_DAYS): finally: clear_rls_context() if deleted: - logger.info( - "Purged %s read notifications older than %s days", deleted, days - ) + logger.info("Purged %s read notifications older than %s days", deleted, days) return deleted diff --git a/backend/common/tests/test_audit_log.py b/backend/common/tests/test_audit_log.py index fae24a69..59059ce7 100644 --- a/backend/common/tests/test_audit_log.py +++ b/backend/common/tests/test_audit_log.py @@ -56,9 +56,7 @@ class TestAuditLoggerMethods: def test_cross_org_attempt(self, admin_user, org_a, org_b): request = self.factory.get("/api/accounts/") - self.logger.cross_org_attempt( - admin_user, org_a, org_b, "Account:123", request - ) + self.logger.cross_org_attempt(admin_user, org_a, org_b, "Account:123", request) log = SecurityAuditLog.objects.filter(event_type="CROSS_ORG_ATTEMPT").first() assert log is not None assert log.success is False @@ -82,7 +80,9 @@ class TestAuditLoggerMethods: def test_membership_revoked(self, admin_user, org_a, regular_user): request = self.factory.post("/api/profiles/revoke/") - self.logger.membership_revoked(admin_user, org_a, revoked_by=regular_user, request=request) + self.logger.membership_revoked( + admin_user, org_a, revoked_by=regular_user, request=request + ) log = SecurityAuditLog.objects.filter(event_type="MEMBERSHIP_REVOKED").first() assert log is not None assert log.user == admin_user diff --git a/backend/common/tests/test_auth.py b/backend/common/tests/test_auth.py index 8d7de25d..9e3ee887 100644 --- a/backend/common/tests/test_auth.py +++ b/backend/common/tests/test_auth.py @@ -33,7 +33,9 @@ class TestMeView: response = unauthenticated_client.get(self.url) assert response.status_code == status.HTTP_401_UNAUTHORIZED - def test_me_returns_organizations(self, admin_client, admin_user, admin_profile, org_a): + def test_me_returns_organizations( + self, admin_client, admin_user, admin_profile, org_a + ): """Me endpoint should include user's organizations.""" response = admin_client.get(self.url) assert response.status_code == status.HTTP_200_OK @@ -130,9 +132,7 @@ class TestTokenRefreshView: OrgAwareRefreshToken.for_user_and_org(admin_user, org_a, admin_profile) ) - first = unauthenticated_client.post( - self.url, {"refresh": token}, format="json" - ) + first = unauthenticated_client.post(self.url, {"refresh": token}, format="json") assert first.status_code == status.HTTP_200_OK replay = unauthenticated_client.post( @@ -146,9 +146,7 @@ class TestTokenRefreshView: """The no-org refresh branch must rotate its token too.""" token = str(OrgAwareRefreshToken.for_user_and_org(admin_user, None)) - first = unauthenticated_client.post( - self.url, {"refresh": token}, format="json" - ) + first = unauthenticated_client.post(self.url, {"refresh": token}, format="json") assert first.status_code == status.HTTP_200_OK replay = unauthenticated_client.post( @@ -164,9 +162,7 @@ class TestTokenRefreshView: OrgAwareRefreshToken.for_user_and_org(admin_user, org_a, admin_profile) ) - first = unauthenticated_client.post( - self.url, {"refresh": token}, format="json" - ) + first = unauthenticated_client.post(self.url, {"refresh": token}, format="json") assert first.status_code == status.HTTP_200_OK second = unauthenticated_client.post( @@ -186,9 +182,7 @@ class TestOrgSwitchView: def test_switch_org_success(self, admin_client, admin_user, org_b): # Give admin_user access to org_b - Profile.objects.create( - user=admin_user, org=org_b, role="USER", is_active=True - ) + Profile.objects.create(user=admin_user, org=org_b, role="USER", is_active=True) response = admin_client.post( self.url, {"org_id": str(org_b.id)}, @@ -216,9 +210,7 @@ class TestOrgSwitchView: def test_switch_org_returns_profile(self, admin_client, admin_user, org_b): """Switch org should return profile details.""" - Profile.objects.create( - user=admin_user, org=org_b, role="USER", is_active=True - ) + Profile.objects.create(user=admin_user, org=org_b, role="USER", is_active=True) response = admin_client.post( self.url, {"org_id": str(org_b.id)}, @@ -282,7 +274,13 @@ class TestOrgSwitchView: assert replay.status_code == status.HTTP_401_UNAUTHORIZED def test_switch_org_returned_refresh_token_is_usable( - self, admin_client, unauthenticated_client, admin_user, org_a, admin_profile, org_b + self, + admin_client, + unauthenticated_client, + admin_user, + org_a, + admin_profile, + org_b, ): """Retiring the old token must not strand the caller.""" Profile.objects.create(user=admin_user, org=org_b, role="USER", is_active=True) @@ -313,9 +311,7 @@ class TestOrgSwitchView: switch would be worse than letting one token expire on its own. """ Profile.objects.create(user=admin_user, org=org_b, role="USER", is_active=True) - response = admin_client.post( - self.url, {"org_id": str(org_b.id)}, format="json" - ) + response = admin_client.post(self.url, {"org_id": str(org_b.id)}, format="json") assert response.status_code == status.HTTP_200_OK def test_switch_org_will_not_revoke_another_users_token( @@ -381,12 +377,20 @@ def _make_fake_id_token( email, picture="https://photo.example.com/pic.jpg", email_verified=True ): """Create a fake JWT-like ID token with the given email in the payload.""" - header = base64.urlsafe_b64encode(json.dumps({"alg": "RS256"}).encode()).decode().rstrip("=") - payload = base64.urlsafe_b64encode( - json.dumps( - {"email": email, "picture": picture, "email_verified": email_verified} - ).encode() - ).decode().rstrip("=") + header = ( + base64.urlsafe_b64encode(json.dumps({"alg": "RS256"}).encode()) + .decode() + .rstrip("=") + ) + payload = ( + base64.urlsafe_b64encode( + json.dumps( + {"email": email, "picture": picture, "email_verified": email_verified} + ).encode() + ) + .decode() + .rstrip("=") + ) signature = base64.urlsafe_b64encode(b"fakesig").decode().rstrip("=") return f"{header}.{payload}.{signature}" @@ -489,8 +493,16 @@ class TestGoogleOAuthCallbackView: mock_response = MagicMock() mock_response.status_code = 200 # Build a token without email - payload = base64.urlsafe_b64encode(json.dumps({"sub": "123"}).encode()).decode().rstrip("=") - header = base64.urlsafe_b64encode(json.dumps({"alg": "RS256"}).encode()).decode().rstrip("=") + payload = ( + base64.urlsafe_b64encode(json.dumps({"sub": "123"}).encode()) + .decode() + .rstrip("=") + ) + header = ( + base64.urlsafe_b64encode(json.dumps({"alg": "RS256"}).encode()) + .decode() + .rstrip("=") + ) sig = base64.urlsafe_b64encode(b"sig").decode().rstrip("=") mock_response.json.return_value = {"id_token": f"{header}.{payload}.{sig}"} mock_post.return_value = mock_response @@ -533,7 +545,9 @@ class TestGoogleOAuthCallbackView: assert User.objects.filter(email="newgoogle@example.com").exists() @patch("common.views.auth_views.requests.post") - def test_successful_oauth_existing_user(self, mock_post, unauthenticated_client, admin_user): + def test_successful_oauth_existing_user( + self, mock_post, unauthenticated_client, admin_user + ): """Successful OAuth with existing user should return tokens without creating new user.""" fake_token = _make_fake_id_token(admin_user.email) mock_response = MagicMock() @@ -555,7 +569,9 @@ class TestGoogleOAuthCallbackView: assert User.objects.count() == user_count_before @patch("common.views.auth_views.requests.post") - def test_token_exchange_failure_empty_content(self, mock_post, unauthenticated_client): + def test_token_exchange_failure_empty_content( + self, mock_post, unauthenticated_client + ): """Non-200 with empty content body should return generic error.""" mock_response = MagicMock() mock_response.status_code = 500 @@ -618,7 +634,9 @@ class TestGoogleOAuthCallbackView: .rstrip("=") ) payload = ( - base64.urlsafe_b64encode(json.dumps({"email": "noclaim@example.com"}).encode()) + base64.urlsafe_b64encode( + json.dumps({"email": "noclaim@example.com"}).encode() + ) .decode() .rstrip("=") ) @@ -636,7 +654,9 @@ class TestGoogleOAuthCallbackView: assert not User.objects.filter(email="noclaim@example.com").exists() @patch("common.views.auth_views.requests.post") - def test_rejects_deactivated_user(self, mock_post, unauthenticated_client, admin_user): + def test_rejects_deactivated_user( + self, mock_post, unauthenticated_client, admin_user + ): """A deactivated account must not be able to log back in via Google.""" admin_user.is_active = False admin_user.save(update_fields=["is_active"]) @@ -667,9 +687,7 @@ class TestGoogleIdTokenView: def test_missing_id_token(self, unauthenticated_client): """Missing idToken should return 400.""" - response = unauthenticated_client.post( - self.url, {}, format="json" - ) + response = unauthenticated_client.post(self.url, {}, format="json") assert response.status_code == status.HTTP_400_BAD_REQUEST assert "Missing idToken" in response.data["error"] @@ -686,7 +704,9 @@ class TestGoogleIdTokenView: @patch("google.oauth2.id_token.verify_oauth2_token") @patch("google.auth.transport.requests.Request") - def test_no_email_in_verified_token(self, mock_request_cls, mock_verify, unauthenticated_client): + def test_no_email_in_verified_token( + self, mock_request_cls, mock_verify, unauthenticated_client + ): """Token without email claim should return 400.""" mock_verify.return_value = {"sub": "12345"} response = unauthenticated_client.post( @@ -697,7 +717,9 @@ class TestGoogleIdTokenView: @patch("google.oauth2.id_token.verify_oauth2_token") @patch("google.auth.transport.requests.Request") - def test_successful_new_user(self, mock_request_cls, mock_verify, unauthenticated_client): + def test_successful_new_user( + self, mock_request_cls, mock_verify, unauthenticated_client + ): """Valid token with new email should create user and return JWT.""" mock_verify.return_value = { "email": "mobileuser@example.com", @@ -720,7 +742,13 @@ class TestGoogleIdTokenView: @patch("google.oauth2.id_token.verify_oauth2_token") @patch("google.auth.transport.requests.Request") def test_successful_existing_user_with_orgs( - self, mock_request_cls, mock_verify, unauthenticated_client, admin_user, admin_profile, org_a + self, + mock_request_cls, + mock_verify, + unauthenticated_client, + admin_user, + admin_profile, + org_a, ): """Existing user should get their organizations in response.""" mock_verify.return_value = { @@ -800,7 +828,9 @@ class TestTokenRefreshUserNotFound: url = "/api/auth/refresh-token/" - def test_refresh_deleted_user(self, unauthenticated_client, admin_user, org_a, admin_profile): + def test_refresh_deleted_user( + self, unauthenticated_client, admin_user, org_a, admin_profile + ): """Refreshing token for a deleted user should return 401.""" token = OrgAwareRefreshToken.for_user_and_org(admin_user, org_a, admin_profile) token_str = str(token) diff --git a/backend/common/tests/test_base_model_audit_stamps.py b/backend/common/tests/test_base_model_audit_stamps.py new file mode 100644 index 00000000..2627d227 --- /dev/null +++ b/backend/common/tests/test_base_model_audit_stamps.py @@ -0,0 +1,204 @@ +"""`BaseModel.save()` stamps `created_by` / `updated_by`, and used to lose them. + +The stamp comes from `crum.get_current_user`, a thread local that only the +request middleware fills. In a Celery worker, a management command, the shell or +a data migration it is None, and the old code answered that by assigning None to +both columns. Two consequences, both silent: + +* an explicitly passed `created_by=` was discarded. `invoices.tasks. + create_invoice_history` passes `created_by=invoice.created_by` and + `updated_by=updated_by` from a task, so the invoice audit trail recorded + nobody; +* on an UPDATE it assigned None to `created_by` too, so any save from a worker + erased an existing row's creator for good. + +Inside a request the request user still wins over anything in the payload, and +that is load-bearing rather than incidental: 15 serializers leave `created_by` or +`updated_by` writable, and this assignment is what keeps a forged value +harmless. `TestTheRequestUserStillWins` is the half that pins it. +""" + +import pytest +from crum import impersonate + +from common.models import Org, User +from leads.models import Lead + + +@pytest.fixture +def other_user(db): + return User.objects.create_user( + email="stamp-other@example.com", password="x", is_active=True + ) + + +@pytest.fixture +def stamp_org(db): + return Org.objects.create(name="Stamp Org") + + +class TestOutsideARequestTheCallerDecides: + """No request means no current user, and nothing to derive a stamp from.""" + + def test_an_explicit_created_by_survives_create(self, db, stamp_org, other_user): + lead = Lead.objects.create( + title="explicit", org=stamp_org, created_by=other_user + ) + + lead.refresh_from_db() + assert lead.created_by == other_user + + def test_an_explicit_updated_by_survives_create(self, db, stamp_org, other_user): + lead = Lead.objects.create( + title="explicit", org=stamp_org, updated_by=other_user + ) + + lead.refresh_from_db() + assert lead.updated_by == other_user + + def test_a_later_save_does_not_erase_created_by(self, db, stamp_org, other_user): + """The destructive half: an UPDATE from a worker nulled the creator.""" + lead = Lead.objects.create( + title="explicit", org=stamp_org, created_by=other_user + ) + + lead.title = "renamed by a background task" + lead.save() + + lead.refresh_from_db() + assert lead.created_by == other_user + + def test_setting_the_attribute_then_saving_persists( + self, db, stamp_org, other_user + ): + """Assignment then `save()`, the shape a data migration uses.""" + lead = Lead.objects.create(title="blank", org=stamp_org) + + lead.created_by = other_user + lead.save() + + lead.refresh_from_db() + assert lead.created_by == other_user + + def test_a_caller_that_says_nothing_still_gets_null(self, db, stamp_org): + """The default is unchanged: no request and no explicit value is None.""" + lead = Lead.objects.create(title="silent", org=stamp_org) + + lead.refresh_from_db() + assert lead.created_by is None + assert lead.updated_by is None + + +class TestTheRequestUserStillWins: + """Inside a request the server decides, whatever the payload said. + + `created_by` is writable on 15 serializers. This branch is what makes that + harmless, so it must keep overwriting rather than deferring to the caller. + """ + + def test_created_by_is_overwritten_on_create(self, db, stamp_org, other_user): + actor = User.objects.create_user( + email="stamp-actor@example.com", password="x", is_active=True + ) + + with impersonate(actor): + lead = Lead.objects.create( + title="forged", org=stamp_org, created_by=other_user + ) + + lead.refresh_from_db() + assert lead.created_by == actor + + def test_updated_by_is_overwritten_on_create(self, db, stamp_org, other_user): + actor = User.objects.create_user( + email="stamp-actor2@example.com", password="x", is_active=True + ) + + with impersonate(actor): + lead = Lead.objects.create( + title="forged", org=stamp_org, updated_by=other_user + ) + + lead.refresh_from_db() + assert lead.updated_by == actor + + def test_created_by_is_not_touched_on_update(self, db, stamp_org, other_user): + """Who made the row, not who last touched it.""" + creator = User.objects.create_user( + email="stamp-creator@example.com", password="x", is_active=True + ) + with impersonate(creator): + lead = Lead.objects.create(title="mine", org=stamp_org) + + editor = User.objects.create_user( + email="stamp-editor@example.com", password="x", is_active=True + ) + with impersonate(editor): + lead.title = "edited by someone else" + lead.save() + + lead.refresh_from_db() + assert lead.created_by == creator + assert lead.updated_by == editor + + def test_an_anonymous_request_defers_to_the_caller(self, db, stamp_org, other_user): + """`AnonymousUser` is treated as no user, the same as no request.""" + from django.contrib.auth.models import AnonymousUser + + with impersonate(AnonymousUser()): + lead = Lead.objects.create( + title="anon", org=stamp_org, created_by=other_user + ) + + lead.refresh_from_db() + assert lead.created_by == other_user + + +class TestTheInvoiceAuditTrailRecordsSomebody: + """The concrete caller that was losing both stamps. + + `create_invoice_history` runs in a worker and passes both columns + explicitly, which is exactly the combination the old code discarded. + + Note the two types. `InvoiceHistory` overrides `updated_by` to point at + `Profile` while the inherited `created_by` still points at `User`, so the + task passes one of each. That override is also why this row must only ever + be written from a worker: with a request user present, `BaseModel.save()` + would assign a `User` to the `Profile` column and raise. Nothing sets + `CELERY_TASK_ALWAYS_EAGER`, so today it cannot happen. + """ + + def test_history_keeps_the_two_users_the_task_passes( + self, db, stamp_org, other_user + ): + from common.models import Profile + from invoices.models import Invoice, InvoiceHistory + + editor = Profile.objects.create( + user=User.objects.create_user( + email="stamp-editor2@example.com", password="x", is_active=True + ), + org=stamp_org, + role="USER", + is_active=True, + ) + invoice = Invoice.objects.create( + invoice_title="INV-1", invoice_number="1", org=stamp_org + ) + + history = InvoiceHistory.objects.create( + invoice=invoice, + invoice_title=invoice.invoice_title, + invoice_number=invoice.invoice_number, + total_amount=10, + currency="USD", + amount_due=10, + status="Draft", + org=stamp_org, + created_by=other_user, + updated_by=editor, + ) + + history.refresh_from_db() + assert history.created_by == other_user + assert history.updated_by == editor diff --git a/backend/common/tests/test_comment_authz.py b/backend/common/tests/test_comment_authz.py index 42893d07..cbca4d4f 100644 --- a/backend/common/tests/test_comment_authz.py +++ b/backend/common/tests/test_comment_authz.py @@ -26,6 +26,7 @@ from accounts.models import Account from common.models import Comment from tasks.models import Task + @pytest.fixture def task_a(admin_user, org_a): return Task.objects.create( @@ -120,9 +121,7 @@ class TestOrdinaryEditStillWorks: comment_on_task.refresh_from_db() assert comment_on_task.comment == "Fixed my typo" - def test_a_third_party_still_cannot_edit( - self, user_client, comment_on_task - ): + def test_a_third_party_still_cannot_edit(self, user_client, comment_on_task): """Author-or-admin gating is untouched by the field change.""" response = user_client.put( f"/api/tasks/comment/{comment_on_task.id}/", diff --git a/backend/common/tests/test_custom_fields.py b/backend/common/tests/test_custom_fields.py index 64f95ddc..f9e927bd 100644 --- a/backend/common/tests/test_custom_fields.py +++ b/backend/common/tests/test_custom_fields.py @@ -210,7 +210,11 @@ class TestCustomFieldDefinitionAPI: types, so they opt out; the payload must then carry neither the per-row count nor the totals block.""" CustomFieldDefinition.objects.create( - org=org_a, target_model="Lead", key="budget", label="Budget", field_type="number" + org=org_a, + target_model="Lead", + key="budget", + label="Budget", + field_type="number", ) response = admin_client.get( self.URL + "?target_model=Lead&include_counts=false" @@ -226,7 +230,11 @@ class TestCustomFieldDefinitionAPI: """The opt-out must be opt-in: an unqualified GET, and an explicit anything-other-than-"false", keep the settings page's numbers.""" CustomFieldDefinition.objects.create( - org=org_a, target_model="Lead", key="budget", label="Budget", field_type="number" + org=org_a, + target_model="Lead", + key="budget", + label="Budget", + field_type="number", ) for query in ("?target_model=Lead", "?target_model=Lead&include_counts=true"): body = admin_client.get(self.URL + query).json() diff --git a/backend/common/tests/test_docs_api_endpoints.py b/backend/common/tests/test_docs_api_endpoints.py index f4cdd45c..5eeb8ab6 100644 --- a/backend/common/tests/test_docs_api_endpoints.py +++ b/backend/common/tests/test_docs_api_endpoints.py @@ -24,7 +24,9 @@ HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE") # Stops at whitespace and at the characters that delimit markdown table cells, # inline code, and quoted strings. ENDPOINT_RE = re.compile( - r"\b(" + "|".join(HTTP_METHODS) + r")\s+(/api/[^\s`'\"|)\]<>]*(?:<[^>]*>[^\s`'\"|)\]<>]*)*)" + r"\b(" + + "|".join(HTTP_METHODS) + + r")\s+(/api/[^\s`'\"|)\]<>]*(?:<[^>]*>[^\s`'\"|)\]<>]*)*)" ) # Path parameters appear as {id} in the schema and as {id} or in docs. @@ -41,9 +43,7 @@ def _normalise_path(path: str) -> str: def _extract_endpoints(text: str) -> list[tuple[str, str]]: - return [ - (method, _normalise_path(raw)) for method, raw in ENDPOINT_RE.findall(text) - ] + return [(method, _normalise_path(raw)) for method, raw in ENDPOINT_RE.findall(text)] def _schema_operations() -> dict[str, set[str]]: @@ -142,7 +142,10 @@ def test_find_missing_reports_a_method_the_path_does_not_accept(): def test_find_missing_is_empty_when_everything_resolves(): operations = {"/api/leads/": {"GET", "POST"}} - documented = [("leads.md", "GET", "/api/leads/"), ("leads.md", "POST", "/api/leads/")] + documented = [ + ("leads.md", "GET", "/api/leads/"), + ("leads.md", "POST", "/api/leads/"), + ] assert _find_missing(documented, operations, set()) == [] diff --git a/backend/common/tests/test_docs_build_excludes_internal.py b/backend/common/tests/test_docs_build_excludes_internal.py index 8a71f8ca..05c29a73 100644 --- a/backend/common/tests/test_docs_build_excludes_internal.py +++ b/backend/common/tests/test_docs_build_excludes_internal.py @@ -44,8 +44,7 @@ def test_built_site_contains_no_internal_artifacts(): leaked = sorted( str(p.relative_to(destination)) for p in destination.rglob("*") - if p.is_file() - and p.relative_to(destination).parts[0] in INTERNAL_PREFIXES + if p.is_file() and p.relative_to(destination).parts[0] in INTERNAL_PREFIXES ) assert not leaked, ( "Internal artifacts were published to the docs site:\n" diff --git a/backend/common/tests/test_docs_environment_variables.py b/backend/common/tests/test_docs_environment_variables.py index d2fa659d..572d1ae2 100644 --- a/backend/common/tests/test_docs_environment_variables.py +++ b/backend/common/tests/test_docs_environment_variables.py @@ -69,7 +69,10 @@ def test_variables_read_by_settings_handles_a_wrapped_call(): ) def test_no_documented_variable_is_read_nowhere(): documented = _documented_variables(REFERENCE_PATH.read_text()) - known = _variables_read_by_settings(SETTINGS_PATH.read_text()) | _variables_in_env_files() + known = ( + _variables_read_by_settings(SETTINGS_PATH.read_text()) + | _variables_in_env_files() + ) invented = sorted(documented - known) assert not invented, ( "These variables are documented but read nowhere in the codebase:\n " diff --git a/backend/common/tests/test_documents.py b/backend/common/tests/test_documents.py index 15686b31..7bd93788 100644 --- a/backend/common/tests/test_documents.py +++ b/backend/common/tests/test_documents.py @@ -57,9 +57,7 @@ class TestDocumentListView: assert "status_choices" in data assert "search" in data - def test_create_document_with_shared_to( - self, admin_client, org_a, admin_profile - ): + def test_create_document_with_shared_to(self, admin_client, org_a, admin_profile): """Create document with shared_to profiles via multipart with JSON string.""" import json @@ -78,9 +76,7 @@ class TestDocumentListView: assert response.status_code == status.HTTP_201_CREATED assert response.data["error"] is False - def test_create_document_with_teams( - self, admin_client, org_a - ): + def test_create_document_with_teams(self, admin_client, org_a): """Create document with teams via multipart with JSON string.""" import json @@ -137,7 +133,9 @@ class TestDocumentListView: assert len(active) == 1 assert active[0]["title"] == "Alpha Doc" - def test_list_documents_filter_by_title_search(self, admin_client, org_a, admin_user): + def test_list_documents_filter_by_title_search( + self, admin_client, org_a, admin_user + ): """Filter documents by title triggers search flag.""" Document.objects.create( title="Searchable Doc", @@ -242,9 +240,7 @@ class TestDocumentDetailView: doc.refresh_from_db() assert doc.shared_to.count() == 1 - def test_update_document_with_teams( - self, admin_client, org_a, admin_user - ): + def test_update_document_with_teams(self, admin_client, org_a, admin_user): """Update document with teams.""" import json diff --git a/backend/common/tests/test_documents_access.py b/backend/common/tests/test_documents_access.py index de276340..81c9762f 100644 --- a/backend/common/tests/test_documents_access.py +++ b/backend/common/tests/test_documents_access.py @@ -237,9 +237,7 @@ class TestDocumentUpdate: doc.refresh_from_db() assert doc.title == "Before" - def test_admin_can_update_anyones_document( - self, admin_client, regular_user, org_a - ): + def test_admin_can_update_anyones_document(self, admin_client, regular_user, org_a): """The permissive direction for an admin: not the creator, still allowed.""" doc = _doc(org_a, regular_user, title="Before") response = admin_client.patch( @@ -299,9 +297,7 @@ class TestDocumentUpdate: doc.refresh_from_db() assert doc.title == "Before" - def test_creator_can_update_via_put( - self, user_client, regular_user, org_a - ): + def test_creator_can_update_via_put(self, user_client, regular_user, org_a): """The creator keeps full edit rights through PUT, not just PATCH.""" doc = _doc(org_a, regular_user, title="Before") response = user_client.put( diff --git a/backend/common/tests/test_duplicate_detection.py b/backend/common/tests/test_duplicate_detection.py index 3cf29660..955239b5 100644 --- a/backend/common/tests/test_duplicate_detection.py +++ b/backend/common/tests/test_duplicate_detection.py @@ -54,19 +54,29 @@ class TestNormalizeDomain: assert DuplicateDetector.normalize_domain(None) == "" def test_https_url(self): - assert DuplicateDetector.normalize_domain("https://example.com") == "example.com" + assert ( + DuplicateDetector.normalize_domain("https://example.com") == "example.com" + ) def test_http_url(self): assert DuplicateDetector.normalize_domain("http://example.com") == "example.com" def test_www_prefix_removed(self): - assert DuplicateDetector.normalize_domain("https://www.example.com") == "example.com" + assert ( + DuplicateDetector.normalize_domain("https://www.example.com") + == "example.com" + ) def test_path_removed(self): - assert DuplicateDetector.normalize_domain("https://example.com/path/page") == "example.com" + assert ( + DuplicateDetector.normalize_domain("https://example.com/path/page") + == "example.com" + ) def test_lowercase(self): - assert DuplicateDetector.normalize_domain("HTTPS://EXAMPLE.COM") == "example.com" + assert ( + DuplicateDetector.normalize_domain("HTTPS://EXAMPLE.COM") == "example.com" + ) def test_bare_domain(self): assert DuplicateDetector.normalize_domain("example.com") == "example.com" @@ -110,15 +120,11 @@ class TestFindDuplicateContacts: Contact.objects.create( first_name="Alice", last_name="Smith", phone="12345", org=org_a ) - dupes = DuplicateDetector.find_duplicate_contacts( - org_a, phone="12345" - ) + dupes = DuplicateDetector.find_duplicate_contacts(org_a, phone="12345") assert len(dupes) == 0 def test_name_match(self, org_a): - c = Contact.objects.create( - first_name="Alice", last_name="Smith", org=org_a - ) + c = Contact.objects.create(first_name="Alice", last_name="Smith", org=org_a) dupes = DuplicateDetector.find_duplicate_contacts( org_a, first_name="alice", last_name="smith" ) @@ -176,12 +182,8 @@ class TestFindDuplicateContacts: def test_partial_name_no_match(self, org_a): """Providing only first_name (no last_name) should not trigger name matching.""" - Contact.objects.create( - first_name="Alice", last_name="Smith", org=org_a - ) - dupes = DuplicateDetector.find_duplicate_contacts( - org_a, first_name="Alice" - ) + Contact.objects.create(first_name="Alice", last_name="Smith", org=org_a) + dupes = DuplicateDetector.find_duplicate_contacts(org_a, first_name="Alice") assert len(dupes) == 0 @@ -196,9 +198,7 @@ class TestFindDuplicateLeads: email="bob@example.com", org=org_a, ) - dupes = DuplicateDetector.find_duplicate_leads( - org_a, email="BOB@EXAMPLE.COM" - ) + dupes = DuplicateDetector.find_duplicate_leads(org_a, email="BOB@EXAMPLE.COM") assert lead in dupes def test_phone_match(self, org_a): @@ -208,15 +208,11 @@ class TestFindDuplicateLeads: phone="555-987-6543", org=org_a, ) - dupes = DuplicateDetector.find_duplicate_leads( - org_a, phone="(555) 987-6543" - ) + dupes = DuplicateDetector.find_duplicate_leads(org_a, phone="(555) 987-6543") assert lead in dupes def test_name_match(self, org_a): - lead = Lead.objects.create( - first_name="Bob", last_name="Jones", org=org_a - ) + lead = Lead.objects.create(first_name="Bob", last_name="Jones", org=org_a) dupes = DuplicateDetector.find_duplicate_leads( org_a, first_name="bob", last_name="jones" ) @@ -229,9 +225,7 @@ class TestFindDuplicateLeads: company_name="Acme Corporation", org=org_a, ) - dupes = DuplicateDetector.find_duplicate_leads( - org_a, company_name="Acme" - ) + dupes = DuplicateDetector.find_duplicate_leads(org_a, company_name="Acme") assert lead in dupes def test_company_name_too_short(self, org_a): @@ -241,9 +235,7 @@ class TestFindDuplicateLeads: company_name="AB Corp", org=org_a, ) - dupes = DuplicateDetector.find_duplicate_leads( - org_a, company_name="AB" - ) + dupes = DuplicateDetector.find_duplicate_leads(org_a, company_name="AB") assert len(dupes) == 0 def test_exclude_id(self, org_a): @@ -282,9 +274,7 @@ class TestFindDuplicateLeads: org=org_a, is_active=False, ) - dupes = DuplicateDetector.find_duplicate_leads( - org_a, email="bob@example.com" - ) + dupes = DuplicateDetector.find_duplicate_leads(org_a, email="bob@example.com") assert len(dupes) == 0 @@ -294,16 +284,12 @@ class TestFindDuplicateAccounts: def test_exact_name_match(self, org_a): acct = Account.objects.create(name="Acme Corp", org=org_a) - dupes = DuplicateDetector.find_duplicate_accounts( - org_a, name="acme corp" - ) + dupes = DuplicateDetector.find_duplicate_accounts(org_a, name="acme corp") assert acct in dupes def test_partial_name_match(self, org_a): acct = Account.objects.create(name="Acme Industries", org=org_a) - dupes = DuplicateDetector.find_duplicate_accounts( - org_a, name="Acme Corp" - ) + dupes = DuplicateDetector.find_duplicate_accounts(org_a, name="Acme Corp") # "Acme Industries" starts with "Acme" so it's a partial match assert acct in dupes @@ -311,9 +297,7 @@ class TestFindDuplicateAccounts: acct = Account.objects.create( name="Acme Corp", email="info@acme.com", org=org_a ) - dupes = DuplicateDetector.find_duplicate_accounts( - org_a, email="INFO@ACME.COM" - ) + dupes = DuplicateDetector.find_duplicate_accounts(org_a, email="INFO@ACME.COM") assert acct in dupes def test_website_match(self, org_a): @@ -328,12 +312,8 @@ class TestFindDuplicateAccounts: assert acct in dupes def test_phone_match(self, org_a): - acct = Account.objects.create( - name="Acme Corp", phone="555-111-2222", org=org_a - ) - dupes = DuplicateDetector.find_duplicate_accounts( - org_a, phone="(555) 111-2222" - ) + acct = Account.objects.create(name="Acme Corp", phone="555-111-2222", org=org_a) + dupes = DuplicateDetector.find_duplicate_accounts(org_a, phone="(555) 111-2222") assert acct in dupes def test_exclude_id(self, org_a): @@ -357,9 +337,7 @@ class TestFindDuplicateAccounts: def test_cross_org_isolation(self, org_a, org_b): Account.objects.create(name="Acme Corp", org=org_b) - dupes = DuplicateDetector.find_duplicate_accounts( - org_a, name="Acme Corp" - ) + dupes = DuplicateDetector.find_duplicate_accounts(org_a, name="Acme Corp") assert len(dupes) == 0 def test_no_args_returns_empty(self, org_a): @@ -370,8 +348,6 @@ class TestFindDuplicateAccounts: def test_short_first_word_no_partial_match(self, org_a): """First word less than 3 chars should not trigger partial matching.""" Account.objects.create(name="AB Industries", org=org_a) - dupes = DuplicateDetector.find_duplicate_accounts( - org_a, name="AB Corp" - ) + dupes = DuplicateDetector.find_duplicate_accounts(org_a, name="AB Corp") # Exact match won't find "AB Industries", and first word "AB" is < 3 chars assert len(dupes) == 0 diff --git a/backend/common/tests/test_email_links.py b/backend/common/tests/test_email_links.py index bbd0f681..1c7ea25b 100644 --- a/backend/common/tests/test_email_links.py +++ b/backend/common/tests/test_email_links.py @@ -191,9 +191,7 @@ class TestTheSecondLeadMailerAgreesWithTheFirst: from leads.tasks import send_lead_assigned_emails - params = list( - inspect.signature(send_lead_assigned_emails.run).parameters - ) + params = list(inspect.signature(send_lead_assigned_emails.run).parameters) assert params == ["lead_id", "new_assigned_to_list", "org_id"] diff --git a/backend/common/tests/test_external_auth.py b/backend/common/tests/test_external_auth.py index 8340b1d3..d6cbc2cf 100644 --- a/backend/common/tests/test_external_auth.py +++ b/backend/common/tests/test_external_auth.py @@ -31,9 +31,7 @@ class TestAPIKeyAuthentication: def test_valid_api_key_returns_user(self, org_a, admin_user, admin_profile): """Valid API key should return (user, None) for an active admin profile.""" - request = self.factory.get( - "/api/some-endpoint/", HTTP_TOKEN=org_a.api_key - ) + request = self.factory.get("/api/some-endpoint/", HTTP_TOKEN=org_a.api_key) user, auth = self.auth.authenticate(request) assert user == admin_user assert auth is None @@ -53,18 +51,14 @@ class TestAPIKeyAuthentication: """API key for inactive org should raise AuthenticationFailed.""" org_a.is_active = False org_a.save() - request = self.factory.get( - "/api/some-endpoint/", HTTP_TOKEN=org_a.api_key - ) + request = self.factory.get("/api/some-endpoint/", HTTP_TOKEN=org_a.api_key) with pytest.raises(AuthenticationFailed, match="Invalid API Key"): self.auth.authenticate(request) def test_no_admin_profile_raises(self, org_a): """Org with no active admin profile should raise AuthenticationFailed.""" # org_a exists but has no profiles at all - request = self.factory.get( - "/api/some-endpoint/", HTTP_TOKEN=org_a.api_key - ) + request = self.factory.get("/api/some-endpoint/", HTTP_TOKEN=org_a.api_key) with pytest.raises(AuthenticationFailed, match="Invalid API Key configuration"): self.auth.authenticate(request) @@ -73,9 +67,7 @@ class TestAPIKeyAuthentication: Profile.objects.create( user=regular_user, org=org_a, role="USER", is_active=True ) - request = self.factory.get( - "/api/some-endpoint/", HTTP_TOKEN=org_a.api_key - ) + request = self.factory.get("/api/some-endpoint/", HTTP_TOKEN=org_a.api_key) with pytest.raises(AuthenticationFailed, match="Invalid API Key configuration"): self.auth.authenticate(request) @@ -84,8 +76,6 @@ class TestAPIKeyAuthentication: Profile.objects.create( user=admin_user, org=org_a, role="ADMIN", is_active=False ) - request = self.factory.get( - "/api/some-endpoint/", HTTP_TOKEN=org_a.api_key - ) + request = self.factory.get("/api/some-endpoint/", HTTP_TOKEN=org_a.api_key) with pytest.raises(AuthenticationFailed, match="Invalid API Key configuration"): self.auth.authenticate(request) diff --git a/backend/common/tests/test_logout.py b/backend/common/tests/test_logout.py index 1c80f0e0..57a5ca1e 100644 --- a/backend/common/tests/test_logout.py +++ b/backend/common/tests/test_logout.py @@ -29,9 +29,7 @@ def _tokens(user, org, profile): class TestSigningOutRevokes: """The whole point: after logout, the refresh token is dead.""" - def test_the_refresh_token_stops_working( - self, admin_user, org_a, admin_profile - ): + def test_the_refresh_token_stops_working(self, admin_user, org_a, admin_profile): client = APIClient() token = _tokens(admin_user, org_a, admin_profile) @@ -50,9 +48,7 @@ class TestSigningOutRevokes: after = client.post(REFRESH_URL, {"refresh": live_refresh}, format="json") assert after.status_code == status.HTTP_401_UNAUTHORIZED - def test_it_works_without_an_access_token( - self, admin_user, org_a, admin_profile - ): + def test_it_works_without_an_access_token(self, admin_user, org_a, admin_profile): # The case the endpoint exists for. A phone that sat in a pocket # presses Sign Out with an access token that expired hours ago. If # logout demanded authentication it would fail here, leaving the @@ -80,9 +76,7 @@ class TestSigningOutRevokes: client.post(URL, {"refresh": str(phone)}, format="json") - still_good = client.post( - REFRESH_URL, {"refresh": str(desktop)}, format="json" - ) + still_good = client.post(REFRESH_URL, {"refresh": str(desktop)}, format="json") assert still_good.status_code == status.HTTP_200_OK @@ -103,9 +97,7 @@ class TestSigningOutIsSafeToRepeat: assert again.status_code == status.HTTP_200_OK def test_a_garbage_token_is_not_a_server_error(self): - response = APIClient().post( - URL, {"refresh": "not-a-jwt"}, format="json" - ) + response = APIClient().post(URL, {"refresh": "not-a-jwt"}, format="json") assert response.status_code == status.HTTP_200_OK def test_a_missing_token_is_a_bad_request(self): @@ -130,9 +122,7 @@ class TestSigningOutCannotReachAnotherSession: # Revoking is scoped to the token presented, not to the user, the org, # or anything the caller could name in the body. - survived = client.post( - REFRESH_URL, {"refresh": str(victim)}, format="json" - ) + survived = client.post(REFRESH_URL, {"refresh": str(victim)}, format="json") assert survived.status_code == status.HTTP_200_OK def test_a_body_naming_someone_else_changes_nothing( @@ -153,7 +143,5 @@ class TestSigningOutCannotReachAnotherSession: ) assert response.status_code == status.HTTP_200_OK - survived = client.post( - REFRESH_URL, {"refresh": str(victim)}, format="json" - ) + survived = client.post(REFRESH_URL, {"refresh": str(victim)}, format="json") assert survived.status_code == status.HTTP_200_OK diff --git a/backend/common/tests/test_magic_link.py b/backend/common/tests/test_magic_link.py index b1e1eed9..f92e7443 100644 --- a/backend/common/tests/test_magic_link.py +++ b/backend/common/tests/test_magic_link.py @@ -66,9 +66,7 @@ class TestMagicLinkRequest: ) with patch("common.tasks.send_magic_link_email") as mock_task: mock_task.delay = lambda *a, **kw: None - unauthenticated_client.post( - self.url, {"email": email}, format="json" - ) + unauthenticated_client.post(self.url, {"email": email}, format="json") # Should still be exactly 5 tokens (no new one created due to rate limit) assert MagicLinkToken.objects.filter(email=email).count() == 5 @@ -81,9 +79,7 @@ class TestMagicLinkRequest: def test_request_missing_email(self, unauthenticated_client): """Missing email should still return 200 (no enumeration).""" - response = unauthenticated_client.post( - self.url, {}, format="json" - ) + response = unauthenticated_client.post(self.url, {}, format="json") assert response.status_code == status.HTTP_200_OK @@ -100,7 +96,9 @@ class TestMagicLinkVerify: expires_at=timezone.now() + timedelta(minutes=10), ) - def test_verify_valid_token_existing_user(self, unauthenticated_client, admin_user, admin_profile): + def test_verify_valid_token_existing_user( + self, unauthenticated_client, admin_user, admin_profile + ): """Valid token for existing user should return JWT tokens.""" token_obj = self._create_valid_token(email=admin_user.email) response = unauthenticated_client.post( @@ -126,9 +124,7 @@ class TestMagicLinkVerify: def test_verify_marks_token_used(self, unauthenticated_client): """Token should be marked as used after verification.""" token_obj = self._create_valid_token() - unauthenticated_client.post( - self.url, {"token": token_obj.token}, format="json" - ) + unauthenticated_client.post(self.url, {"token": token_obj.token}, format="json") token_obj.refresh_from_db() assert token_obj.is_used is True assert token_obj.used_at is not None @@ -164,9 +160,7 @@ class TestMagicLinkVerify: def test_verify_missing_token(self, unauthenticated_client): """Missing token field should be rejected.""" - response = unauthenticated_client.post( - self.url, {}, format="json" - ) + response = unauthenticated_client.post(self.url, {}, format="json") assert response.status_code == status.HTTP_400_BAD_REQUEST def test_verify_returns_org_for_existing_user( diff --git a/backend/common/tests/test_malformed_id_filters.py b/backend/common/tests/test_malformed_id_filters.py index 2ca12b9f..fc391d4c 100644 --- a/backend/common/tests/test_malformed_id_filters.py +++ b/backend/common/tests/test_malformed_id_filters.py @@ -120,7 +120,5 @@ class TestSharedToAcceptsABareId: assert response.status_code == status.HTTP_200_OK def test_json_array_is_still_accepted(self, admin_client): - response = admin_client.get( - f'/api/documents/?shared_to=["{WELL_FORMED}"]' - ) + response = admin_client.get(f'/api/documents/?shared_to=["{WELL_FORMED}"]') assert response.status_code == status.HTTP_200_OK diff --git a/backend/common/tests/test_malformed_id_routes.py b/backend/common/tests/test_malformed_id_routes.py index 4910980a..530380a9 100644 --- a/backend/common/tests/test_malformed_id_routes.py +++ b/backend/common/tests/test_malformed_id_routes.py @@ -85,7 +85,9 @@ def test_no_route_answers_500_for_a_malformed_id(admin_client): try: code = getattr(admin_client, method)(url, {}, format="json").status_code except Exception as exc: # noqa: BLE001 - failures.append(f"{method.upper()} {url} raised {type(exc).__name__}: {exc}") + failures.append( + f"{method.upper()} {url} raised {type(exc).__name__}: {exc}" + ) continue if code >= 500: failures.append(f"{method.upper()} {url} answered {code}") diff --git a/backend/common/tests/test_models.py b/backend/common/tests/test_models.py index f3503b6d..6c694a75 100644 --- a/backend/common/tests/test_models.py +++ b/backend/common/tests/test_models.py @@ -30,7 +30,6 @@ from common.models import ( Teams, ) - # --------------------------------------------------------------------------- # CommentFiles.save() auto-populate org # --------------------------------------------------------------------------- diff --git a/backend/common/tests/test_multitenancy.py b/backend/common/tests/test_multitenancy.py index f825ab34..d6b2a39c 100644 --- a/backend/common/tests/test_multitenancy.py +++ b/backend/common/tests/test_multitenancy.py @@ -539,7 +539,6 @@ class TestRLSIntegration(MultiTenancyBaseTestCase): from django.db import connection - with connection.cursor() as cursor: for table in ["lead", "accounts", "contacts"]: cursor.execute( diff --git a/backend/common/tests/test_notifications.py b/backend/common/tests/test_notifications.py index c60a929b..263cee70 100644 --- a/backend/common/tests/test_notifications.py +++ b/backend/common/tests/test_notifications.py @@ -74,9 +74,7 @@ class TestNotificationModel(NotificationModelBase): verb="case.assigned", read_at=timezone.now(), ) - qs = Notification.objects.filter( - recipient=self.profile_a, read_at__isnull=True - ) + qs = Notification.objects.filter(recipient=self.profile_a, read_at__isnull=True) ids = list(qs.values_list("id", flat=True)) assert unread.id in ids assert read.id not in ids @@ -255,10 +253,17 @@ class TestPurgeTask(NotificationModelBase): verb="x", read_at=timezone.now() - timedelta(days=120), ) - with mock.patch.object( - tasks_module, "set_rls_context", side_effect=lambda org_id: seen.append(str(org_id)) - ), mock.patch.object( - tasks_module, "clear_rls_context", side_effect=lambda: cleared.append(True) + with ( + mock.patch.object( + tasks_module, + "set_rls_context", + side_effect=lambda org_id: seen.append(str(org_id)), + ), + mock.patch.object( + tasks_module, + "clear_rls_context", + side_effect=lambda: cleared.append(True), + ), ): tasks_module.purge_read_notifications() diff --git a/backend/common/tests/test_organizations.py b/backend/common/tests/test_organizations.py index 040f84b6..d2e5d763 100644 --- a/backend/common/tests/test_organizations.py +++ b/backend/common/tests/test_organizations.py @@ -49,9 +49,7 @@ class TestOrgProfileCreateView: assert response.status_code == status.HTTP_200_OK assert "id" in response.data["org"] assert response.data["org"]["id"] is not None - created = Profile.objects.get( - user=admin_user, org__name="Org With Id" - ).org + created = Profile.objects.get(user=admin_user, org__name="Org With Id").org assert str(response.data["org"]["id"]) == str(created.id) def test_create_org_ignores_client_supplied_id(self, admin_client, admin_user): @@ -215,9 +213,7 @@ class TestOrgUpdateView: def test_update_org_accepts_a_name_at_the_limit(self, admin_client, org_a): """The validator has to be able to say yes at exactly 100 characters.""" name = "X" * 100 - response = admin_client.put( - self._url(org_a.id), {"name": name}, format="json" - ) + response = admin_client.put(self._url(org_a.id), {"name": name}, format="json") assert response.status_code == status.HTTP_200_OK org_a.refresh_from_db() @@ -748,7 +744,10 @@ class TestTimezoneList: def test_every_zone_carries_its_current_offset(self, admin_client): """Mobile has no way to read the device's IANA zone name without a platform package, so it preselects by matching the device's offset.""" - zones = {z["name"]: z["offset_minutes"] for z in admin_client.get(self.url).data["timezones"]} + zones = { + z["name"]: z["offset_minutes"] + for z in admin_client.get(self.url).data["timezones"] + } assert zones["UTC"] == 0 assert zones["Asia/Kolkata"] == 330 diff --git a/backend/common/tests/test_packs_api.py b/backend/common/tests/test_packs_api.py index 418eaa03..1d63ce03 100644 --- a/backend/common/tests/test_packs_api.py +++ b/backend/common/tests/test_packs_api.py @@ -64,9 +64,7 @@ class TestPackAPI: assert res.json() == {"error": True, "errors": "Unknown pack."} def test_org_in_body_is_ignored(self, admin_client, org_a, org_b): - res = admin_client.post( - "/api/packs/real-estate/apply/", {"org": str(org_b.id)} - ) + res = admin_client.post("/api/packs/real-estate/apply/", {"org": str(org_b.id)}) assert res.status_code == 200 from leads.models import LeadPipeline diff --git a/backend/common/tests/test_packs_applier.py b/backend/common/tests/test_packs_applier.py index 018784c7..3b7ace8c 100644 --- a/backend/common/tests/test_packs_applier.py +++ b/backend/common/tests/test_packs_applier.py @@ -13,12 +13,18 @@ def test_apply_creates_every_element_from_the_manifest(org_a, admin_profile): # Manifest↔database equivalence: counts per type must match the manifest. assert LeadPipeline.objects.filter(org=org_a).count() == 1 - assert LeadStage.objects.filter(org=org_a).count() == len(pack["lead_pipeline"]["stages"]) - assert CustomFieldDefinition.objects.filter(org=org_a).count() == len(pack["custom_fields"]) + assert LeadStage.objects.filter(org=org_a).count() == len( + pack["lead_pipeline"]["stages"] + ) + assert CustomFieldDefinition.objects.filter(org=org_a).count() == len( + pack["custom_fields"] + ) # +1 for the reserved "Sample data" tag the applier creates alongside any # pack that carries a sample_data block (common/packs/applier.py). sample_tag_count = 1 if (pack.get("sample_data") or {}).get("leads") else 0 - assert Tags.objects.filter(org=org_a).count() == len(pack["tags"]) + sample_tag_count + assert ( + Tags.objects.filter(org=org_a).count() == len(pack["tags"]) + sample_tag_count + ) assert report["failed"] == [] diff --git a/backend/common/tests/test_packs_sample_data.py b/backend/common/tests/test_packs_sample_data.py index b42e7f3c..681e059f 100644 --- a/backend/common/tests/test_packs_sample_data.py +++ b/backend/common/tests/test_packs_sample_data.py @@ -214,7 +214,9 @@ def test_apply_creates_every_sample_entity_type(org_a, admin_profile, pack_id): ("leads", Lead), ): created = model.objects.filter(org=org_a, is_sample=True).count() - assert created == len(sample[key]), f"{pack_id}: expected {len(sample[key])} {key}, got {created}" + assert created == len(sample[key]), ( + f"{pack_id}: expected {len(sample[key])} {key}, got {created}" + ) @pytest.mark.django_db @@ -349,7 +351,10 @@ def test_clear_does_not_raise_when_a_sample_account_has_a_protected_invoice( apply_pack(org_a, get_pack("real-estate"), admin_profile) account = Account.objects.get(org=org_a, name="Cedar Grove Estates") invoice = Invoice.objects.create( - org=org_a, invoice_title="A real invoice", invoice_number="INV-REAL-1", account=account + org=org_a, + invoice_title="A real invoice", + invoice_number="INV-REAL-1", + account=account, ) result = clear_sample_data(org_a, admin_profile) # must not raise diff --git a/backend/common/tests/test_pat_api.py b/backend/common/tests/test_pat_api.py index f9b303f2..47b1f819 100644 --- a/backend/common/tests/test_pat_api.py +++ b/backend/common/tests/test_pat_api.py @@ -1,8 +1,9 @@ import uuid import pytest -from common.serializer import PersonalAccessTokenListSerializer + from common.models import PersonalAccessToken, Profile, User +from common.serializer import PersonalAccessTokenListSerializer @pytest.mark.django_db @@ -76,7 +77,9 @@ class TestPATApi: ) assert resp.status_code == 400, resp.content - def test_create_ignores_injected_protected_fields(self, admin_client, admin_profile, org_a): + def test_create_ignores_injected_protected_fields( + self, admin_client, admin_profile, org_a + ): # A client must not be able to set org / profile / token_hash / revoked_at. resp = admin_client.post( self.LIST_URL, @@ -104,7 +107,9 @@ class TestPATApi: assert str(foreign.id) not in ids assert len(lst["tokens"]) == 1 - def test_cannot_revoke_others_token(self, admin_client, admin_profile, other_profile): + def test_cannot_revoke_others_token( + self, admin_client, admin_profile, other_profile + ): _, foreign = PersonalAccessToken.generate(profile=other_profile, name="theirs") resp = admin_client.delete(self._detail_url(foreign.id)) assert resp.status_code == 404, resp.content diff --git a/backend/common/tests/test_pat_auth.py b/backend/common/tests/test_pat_auth.py index dfd649a2..b9a7d023 100644 --- a/backend/common/tests/test_pat_auth.py +++ b/backend/common/tests/test_pat_auth.py @@ -1,5 +1,6 @@ -import pytest from datetime import timedelta + +import pytest from django.db import connection from django.test import Client, RequestFactory from django.utils import timezone @@ -36,14 +37,16 @@ class TestPATAuthentication: def test_revoked_pat_raises(self, admin_profile): raw, pat = PersonalAccessToken.generate(profile=admin_profile, name="cli") - pat.revoked_at = timezone.now(); pat.save() + pat.revoked_at = timezone.now() + pat.save() req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}") with pytest.raises(AuthenticationFailed): self.auth.authenticate(req) def test_expired_pat_raises(self, admin_profile): raw, _ = PersonalAccessToken.generate( - profile=admin_profile, name="cli", + profile=admin_profile, + name="cli", expires_at=timezone.now() - timedelta(days=1), ) req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}") @@ -57,7 +60,8 @@ class TestPATAuthentication: def test_inactive_profile_raises(self, admin_profile): raw, _ = PersonalAccessToken.generate(profile=admin_profile, name="cli") - admin_profile.is_active = False; admin_profile.save() + admin_profile.is_active = False + admin_profile.save() req = self.factory.get("/api/leads/", HTTP_AUTHORIZATION=f"Bearer {raw}") with pytest.raises(AuthenticationFailed): self.auth.authenticate(req) @@ -122,7 +126,9 @@ def test_cross_org_pat_cannot_read_other_orgs_leads(org_a, admin_profile): on SQLite it cleanly skips. """ if connection.vendor != "postgresql": - pytest.skip("RLS isolation is enforced by PostgreSQL; skipping on non-Postgres DB") + pytest.skip( + "RLS isolation is enforced by PostgreSQL; skipping on non-Postgres DB" + ) from common.tasks import clear_rls_context, set_rls_context from leads.models import Lead diff --git a/backend/common/tests/test_pat_integration.py b/backend/common/tests/test_pat_integration.py index b1dda28b..ea294ff1 100644 --- a/backend/common/tests/test_pat_integration.py +++ b/backend/common/tests/test_pat_integration.py @@ -50,9 +50,7 @@ class TestPATThroughMiddlewareStack: def test_invalid_pat_is_denied(self, admin_profile): """A bogus PAT must be denied (not 500, not 200).""" client = Client() - resp = client.get( - self.PROBE_URL, HTTP_AUTHORIZATION="Bearer bcrm_pat_bogus" - ) + resp = client.get(self.PROBE_URL, HTTP_AUTHORIZATION="Bearer bcrm_pat_bogus") assert resp.status_code in (401, 403), resp.content assert resp.status_code not in (200, 500) diff --git a/backend/common/tests/test_pat_model.py b/backend/common/tests/test_pat_model.py index a36f148a..9cf61baf 100644 --- a/backend/common/tests/test_pat_model.py +++ b/backend/common/tests/test_pat_model.py @@ -1,8 +1,8 @@ import hashlib +from datetime import timedelta import pytest from django.utils import timezone -from datetime import timedelta from common.models import PersonalAccessToken @@ -32,7 +32,8 @@ class TestPersonalAccessToken: def test_is_valid_false_when_expired(self, admin_profile): _, pat = PersonalAccessToken.generate( - profile=admin_profile, name="x", + profile=admin_profile, + name="x", expires_at=timezone.now() - timedelta(days=1), ) assert pat.is_valid() is False @@ -49,6 +50,7 @@ def test_pat_table_has_no_rls_policy(): not by a row-level security policy. Assert no policy exists on the table. """ from django.db import connection + if connection.vendor != "postgresql": pytest.skip("RLS requires PostgreSQL") with connection.cursor() as cur: diff --git a/backend/common/tests/test_pat_org_api.py b/backend/common/tests/test_pat_org_api.py index 9607cad3..43da6937 100644 --- a/backend/common/tests/test_pat_org_api.py +++ b/backend/common/tests/test_pat_org_api.py @@ -27,7 +27,9 @@ def _detail_url(pk): @pytest.mark.django_db class TestOrgTokenList: - def test_admin_sees_every_owner_in_org(self, admin_client, admin_profile, user_profile): + def test_admin_sees_every_owner_in_org( + self, admin_client, admin_profile, user_profile + ): # Two owners, one org. The self-scoped list would show only the admin's; # this oversight list must show both. _, mine = PersonalAccessToken.generate(profile=admin_profile, name="mine") @@ -55,7 +57,9 @@ class TestOrgTokenList: def test_cross_org_isolation(self, org_b_client, admin_profile, profile_b): # No RLS on this table: the org filter is the ONLY barrier. Org B's admin # must never see org A's token. - _, a_token = PersonalAccessToken.generate(profile=admin_profile, name="org-a-secret") + _, a_token = PersonalAccessToken.generate( + profile=admin_profile, name="org-a-secret" + ) _, b_token = PersonalAccessToken.generate(profile=profile_b, name="org-b-own") body = org_b_client.get(LIST_URL).json() @@ -103,7 +107,9 @@ class TestOrgTokenList: last_used_at=timezone.now() - timedelta(days=120) ) _, fresh = PersonalAccessToken.generate(profile=admin_profile, name="fresh") - PersonalAccessToken.objects.filter(pk=fresh.pk).update(last_used_at=timezone.now()) + PersonalAccessToken.objects.filter(pk=fresh.pk).update( + last_used_at=timezone.now() + ) body = admin_client.get(LIST_URL).json() assert body["totals"]["unused_90d"] == 1 diff --git a/backend/common/tests/test_pat_scope_enforcement.py b/backend/common/tests/test_pat_scope_enforcement.py index 4796fc80..b1e6a067 100644 --- a/backend/common/tests/test_pat_scope_enforcement.py +++ b/backend/common/tests/test_pat_scope_enforcement.py @@ -31,7 +31,8 @@ class TestScopeEnforcement: def test_unscoped_token_still_writes(self, admin_profile): resp = _pat_client(admin_profile).post( - "/api/leads/", {"title": "Scope check", "first_name": "Un", "last_name": "Scoped"} + "/api/leads/", + {"title": "Scope check", "first_name": "Un", "last_name": "Scoped"}, ) assert resp.status_code not in (401, 403) @@ -91,7 +92,12 @@ class TestCredentialDenyList: assert PersonalAccessToken.objects.count() == before def test_session_can_mint_a_token(self, admin_client): - assert admin_client.post("/api/profile/tokens/", {"name": "from-browser"}).status_code == 201 + assert ( + admin_client.post( + "/api/profile/tokens/", {"name": "from-browser"} + ).status_code + == 201 + ) def test_full_access_token_still_cannot_mint(self, admin_profile): """`[]` means unrestricted for ordinary endpoints, and still not for this one.""" @@ -157,7 +163,9 @@ class TestScopeValidationAtCreation: assert not ser.is_valid() def test_malformed_scope_rejected(self): - ser = PersonalAccessTokenCreateSerializer(data={"name": "cli", "scopes": ["leads"]}) + ser = PersonalAccessTokenCreateSerializer( + data={"name": "cli", "scopes": ["leads"]} + ) assert not ser.is_valid() def test_empty_list_still_accepted(self): diff --git a/backend/common/tests/test_rls_pipeline_tables.py b/backend/common/tests/test_rls_pipeline_tables.py index 04fd5984..6ef72fab 100644 --- a/backend/common/tests/test_rls_pipeline_tables.py +++ b/backend/common/tests/test_rls_pipeline_tables.py @@ -84,5 +84,7 @@ def test_pipeline_policies_use_the_org_context_variable(): ) row = cur.fetchone() assert row is not None, f"{table} has no org_isolation policy" - assert "app.current_org" in row[0], f"{table}: unexpected predicate {row[0]}" + assert "app.current_org" in row[0], ( + f"{table}: unexpected predicate {row[0]}" + ) assert "org_id" in row[0], f"{table}: predicate does not filter org_id" diff --git a/backend/common/tests/test_scopes.py b/backend/common/tests/test_scopes.py index 980ac052..c22fbc59 100644 --- a/backend/common/tests/test_scopes.py +++ b/backend/common/tests/test_scopes.py @@ -113,7 +113,10 @@ class TestScopesAllow: with nothing usable is refused rather than treated as unrestricted. """ assert scopes.scopes_allow(["not-a-scope"], "GET", "/api/leads/") is False - assert scopes.scopes_allow(["not-a-scope", "leads:read"], "GET", "/api/leads/") is True + assert ( + scopes.scopes_allow(["not-a-scope", "leads:read"], "GET", "/api/leads/") + is True + ) class TestCredentialPaths: @@ -129,7 +132,9 @@ class TestCredentialPaths: def test_credential_paths_are_denied(self, path): assert scopes.credential_path_denial(path) is not None - @pytest.mark.parametrize("path", ["/api/leads/", "/api/org/settings/", "/api/profile/"]) + @pytest.mark.parametrize( + "path", ["/api/leads/", "/api/org/settings/", "/api/profile/"] + ) def test_ordinary_paths_are_not_denied(self, path): assert scopes.credential_path_denial(path) is None @@ -141,7 +146,10 @@ class TestCredentialPaths: credential that outlives its own revocation. """ assert scopes.check_request([], "GET", "/api/org/api-key/") is not None - assert scopes.check_request(["*:write"], "POST", "/api/profile/tokens/") is not None + assert ( + scopes.check_request(["*:write"], "POST", "/api/profile/tokens/") + is not None + ) def test_check_request_allows_an_ordinary_scoped_read(self): assert scopes.check_request(["leads:read"], "GET", "/api/leads/") is None diff --git a/backend/common/tests/test_search.py b/backend/common/tests/test_search.py index 21dff963..d6a4bbe8 100644 --- a/backend/common/tests/test_search.py +++ b/backend/common/tests/test_search.py @@ -33,7 +33,9 @@ def _seed_all(org, token="Zephyr"): first_name=token, last_name="Person", email="c@example.com", org=org ) Opportunity.objects.create(name=f"{token} deal", stage="PROSPECTING", org=org) - Case.objects.create(name=f"{token} ticket", status="New", priority="Normal", org=org) + Case.objects.create( + name=f"{token} ticket", status="New", priority="Normal", org=org + ) billing = Account.objects.create(name="Billing Co", org=org) Invoice.objects.create( invoice_title=f"{token} invoice", @@ -54,9 +56,7 @@ def _seed_all(org, token="Zephyr"): @pytest.mark.django_db class TestGlobalSearch: - def test_short_query_returns_empty_even_with_matches( - self, admin_client, org_a - ): + def test_short_query_returns_empty_even_with_matches(self, admin_client, org_a): _seed_all(org_a, token="Zephyr") # One character is below the floor, so it matches nothing on purpose... assert admin_client.get(f"{URL}?q=Z").json()["results"] == [] diff --git a/backend/common/tests/test_teams.py b/backend/common/tests/test_teams.py index 1b3a9743..e0b329d2 100644 --- a/backend/common/tests/test_teams.py +++ b/backend/common/tests/test_teams.py @@ -139,9 +139,7 @@ class TestTeamsDetailView: response = user_client.delete(self._url(team.pk)) assert response.status_code == status.HTTP_403_FORBIDDEN - def test_update_team_with_users( - self, admin_client, org_a, admin_profile - ): + def test_update_team_with_users(self, admin_client, org_a, admin_profile): """Update team with assigned users.""" team = Teams.objects.create(name="Assign Team", org=org_a) response = admin_client.put( diff --git a/backend/common/tests/test_timezone_dates.py b/backend/common/tests/test_timezone_dates.py index f9ed81c5..84ad0b4a 100644 --- a/backend/common/tests/test_timezone_dates.py +++ b/backend/common/tests/test_timezone_dates.py @@ -188,9 +188,7 @@ class TestOverdueUsesTheOrgDay: early, west of UTC it is late a few hours before it is actually due, and only one of those two directions can catch each mistake.""" - def test_east_of_utc_yesterdays_invoice_is_overdue( - self, org_a, admin_user, frozen - ): + def test_east_of_utc_yesterdays_invoice_is_overdue(self, org_a, admin_user, frozen): _set_rls(org_a) with _as_org("Asia/Kolkata"): # The org is on 7 August; UTC is still on the 6th. diff --git a/backend/common/tests/test_today.py b/backend/common/tests/test_today.py index 1399c045..6496bf26 100644 --- a/backend/common/tests/test_today.py +++ b/backend/common/tests/test_today.py @@ -334,9 +334,7 @@ class TestTheHeaderCountMatchesThePage: assert summary["count"] == 12 assert summary["shown"] == TODAY_QUEUE_LIMIT - def test_the_page_can_tell_it_is_not_showing_everything( - self, admin_client, org_a - ): + def test_the_page_can_tell_it_is_not_showing_everything(self, admin_client, org_a): """The closing line is the page's to draw, and this pair is what it draws from. With more than fits, shown < count; with fewer, they agree and the page may say "that's everything" truthfully.""" diff --git a/backend/common/tests/test_users.py b/backend/common/tests/test_users.py index db4197c5..a1a822d7 100644 --- a/backend/common/tests/test_users.py +++ b/backend/common/tests/test_users.py @@ -43,9 +43,7 @@ class TestUsersListView: # Collect all user emails across active and inactive lists active = response.data["active_users"]["active_users"] inactive = response.data["inactive_users"]["inactive_users"] - all_emails = [ - p["user_details"]["email"] for p in active - ] + [ + all_emails = [p["user_details"]["email"] for p in active] + [ p["user_details"]["email"] for p in inactive ] assert admin_user.email not in all_emails @@ -346,9 +344,7 @@ class TestUserDetailView: response = user_client.get(self._url(admin_user.id)) assert response.status_code == status.HTTP_403_FORBIDDEN - def test_update_user_non_admin_own( - self, user_client, regular_user, user_profile - ): + def test_update_user_non_admin_own(self, user_client, regular_user, user_profile): """Non-admin can update their own profile.""" response = user_client.put( self._url(regular_user.id), @@ -443,9 +439,7 @@ class TestUserStatusView: def _url(self, user_id): return f"/api/user/{user_id}/status/" - def test_activate_user( - self, admin_client, org_a, regular_user, user_profile - ): + def test_activate_user(self, admin_client, org_a, regular_user, user_profile): """Admin can activate a user.""" user_profile.is_active = False user_profile.save() @@ -458,9 +452,7 @@ class TestUserStatusView: user_profile.refresh_from_db() assert user_profile.is_active is True - def test_deactivate_user( - self, admin_client, org_a, regular_user, user_profile - ): + def test_deactivate_user(self, admin_client, org_a, regular_user, user_profile): """Admin can deactivate a user.""" response = admin_client.post( self._url(regular_user.id), @@ -471,9 +463,7 @@ class TestUserStatusView: user_profile.refresh_from_db() assert user_profile.is_active is False - def test_invalid_status( - self, admin_client, org_a, regular_user, user_profile - ): + def test_invalid_status(self, admin_client, org_a, regular_user, user_profile): """Invalid status value returns 400.""" response = admin_client.post( self._url(regular_user.id), diff --git a/backend/common/urls.py b/backend/common/urls.py index cf55895d..b07dfb09 100644 --- a/backend/common/urls.py +++ b/backend/common/urls.py @@ -3,12 +3,12 @@ from django.urls import path from common.views.auth_views import ( GoogleIdTokenView, GoogleOAuthCallbackView, + LogoutView, MagicLinkRequestView, MagicLinkVerifyCodeView, MagicLinkVerifyView, MeView, OrgAwareTokenRefreshView, - LogoutView, OrgSwitchView, ) from common.views.custom_field_views import ( diff --git a/backend/common/utils.py b/backend/common/utils.py index d39e1e15..89575200 100644 --- a/backend/common/utils.py +++ b/backend/common/utils.py @@ -1,7 +1,6 @@ import pytz from django.utils.translation import gettext_lazy as _ - INDCHOICES = ( ("ADVERTISING", "ADVERTISING"), ("AGRICULTURE", "AGRICULTURE"), @@ -446,6 +445,7 @@ def get_or_create_tags(tag_data, org): List of Tag objects """ import json + from common.models import Tags tags = [] diff --git a/backend/common/validators.py b/backend/common/validators.py index ea118753..08d7b2c5 100644 --- a/backend/common/validators.py +++ b/backend/common/validators.py @@ -14,7 +14,6 @@ from django.utils.dateparse import parse_date, parse_datetime from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError as DRFValidationError - # E.164 format: +[country code][number] (max 15 digits total) # Examples: +12025551234, +442071234567 e164_phone_validator = RegexValidator( @@ -52,9 +51,7 @@ def validate_uuid(value, field: str) -> str: try: uuid_module.UUID(str(value)) except (AttributeError, TypeError, ValueError): - raise DRFValidationError( - {field: [f"'{value}' is not a valid id."]} - ) from None + raise DRFValidationError({field: [f"'{value}' is not a valid id."]}) from None return value diff --git a/backend/common/views/auth_views.py b/backend/common/views/auth_views.py index 3b1b949f..e5408309 100644 --- a/backend/common/views/auth_views.py +++ b/backend/common/views/auth_views.py @@ -9,12 +9,12 @@ from django.conf import settings from django.contrib.auth.hashers import make_password from django.utils import timezone from drf_spectacular.utils import extend_schema, inline_serializer - from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.authentication import JWTAuthentication + from common import serializer from common.models import Org, Profile, User from common.serializer import OrgAwareRefreshToken @@ -257,9 +257,8 @@ class GoogleIdTokenView(APIView): ) def post(self, request): from django.utils import timezone - - from google.oauth2 import id_token from google.auth.transport import requests as google_requests + from google.oauth2 import id_token id_token_str = request.data.get("idToken") if not id_token_str: @@ -396,7 +395,6 @@ class OrgAwareTokenRefreshView(APIView): ) def post(self, request): from django.db import transaction - from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.tokens import RefreshToken as BaseRefreshToken @@ -706,10 +704,12 @@ class MagicLinkRequestView(APIView): @extend_schema( tags=["auth"], request=serializer.MagicLinkRequestSerializer, - responses={200: inline_serializer( - name="MagicLinkRequestResponse", - fields={"message": serializers.CharField()}, - )}, + responses={ + 200: inline_serializer( + name="MagicLinkRequestResponse", + fields={"message": serializers.CharField()}, + ) + }, ) def post(self, request): from common.models import MagicLinkToken diff --git a/backend/common/views/document_views.py b/backend/common/views/document_views.py index 7856dfa7..3325c69e 100644 --- a/backend/common/views/document_views.py +++ b/backend/common/views/document_views.py @@ -1,7 +1,6 @@ from django.db.models import Q from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema, inline_serializer - from rest_framework import serializers, status from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import IsAuthenticated @@ -11,7 +10,6 @@ from rest_framework.views import APIView from common import swagger_params from common.models import Document, Profile, Teams from common.permissions import HasOrgContext, is_org_admin -from common.validators import payload_id_list from common.serializer import ( DocumentCreateSerializer, DocumentCreateSwaggerSerializer, @@ -19,6 +17,7 @@ from common.serializer import ( DocumentSerializer, ProfileSerializer, ) +from common.validators import payload_id_list def _visible_to(profile): diff --git a/backend/common/views/pat_views.py b/backend/common/views/pat_views.py index 2999cc0e..43baa6a5 100644 --- a/backend/common/views/pat_views.py +++ b/backend/common/views/pat_views.py @@ -170,9 +170,7 @@ class OrgAccessTokenDetailView(APIView): @extend_schema(tags=["API Tokens"], operation_id="org_pat_revoke") def delete(self, request, pk): - pat = get_object_or_404( - PersonalAccessToken, pk=pk, org=request.profile.org - ) + pat = get_object_or_404(PersonalAccessToken, pk=pk, org=request.profile.org) if pat.revoked_at is None: pat.revoked_at = timezone.now() pat.save(update_fields=["revoked_at"]) diff --git a/backend/common/views/settings_views.py b/backend/common/views/settings_views.py index 4319210f..85a6e47e 100644 --- a/backend/common/views/settings_views.py +++ b/backend/common/views/settings_views.py @@ -1,6 +1,5 @@ from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema, inline_serializer - from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response diff --git a/backend/common/views/team_views.py b/backend/common/views/team_views.py index d20f250a..7c9b6add 100644 --- a/backend/common/views/team_views.py +++ b/backend/common/views/team_views.py @@ -1,5 +1,4 @@ from drf_spectacular.utils import extend_schema, inline_serializer - from rest_framework import serializers, status from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import IsAuthenticated diff --git a/backend/common/views/user_views.py b/backend/common/views/user_views.py index dd2c7c8d..b2182889 100644 --- a/backend/common/views/user_views.py +++ b/backend/common/views/user_views.py @@ -4,7 +4,6 @@ from django.db.models import Count, Q from django.shortcuts import get_object_or_404 from django.utils import timezone from drf_spectacular.utils import extend_schema, inline_serializer - from rest_framework import serializers, status from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import IsAuthenticated @@ -108,9 +107,7 @@ class UsersListView(APIView, LimitOffsetPagination): ) params = request.data if params: - user_serializer = CreateUserSerializer( - data=params, org=request.profile.org - ) + user_serializer = CreateUserSerializer(data=params, org=request.profile.org) address_serializer = BillingAddressSerializer(data=params) # This POST is already gated to admins above, and inviting someone # inherently means choosing their role, so role is grantable here. @@ -346,7 +343,9 @@ class UserDetailView(APIView): cases = Case.objects.filter(assigned_to=profile_obj, org=request.profile.org) context["cases"] = CaseSerializer(cases, many=True).data context["assigned_data"] = assigned_data - comments = Comment.objects.filter(commented_by=profile_obj, org=request.profile.org) + comments = Comment.objects.filter( + commented_by=profile_obj, org=request.profile.org + ) context["comments"] = CommentSerializer(comments, many=True).data context["countries"] = COUNTRIES return Response( diff --git a/backend/conftest.py b/backend/conftest.py index 5c610cd0..8e6eba89 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -203,9 +203,7 @@ def user_profile(regular_user, org_a): @pytest.fixture def profile_b(user_b, org_b): - return Profile.objects.create( - user=user_b, org=org_b, role="ADMIN", is_active=True - ) + return Profile.objects.create(user=user_b, org=org_b, role="ADMIN", is_active=True) def _make_authenticated_client(user, org, profile): diff --git a/backend/contacts/import_views.py b/backend/contacts/import_views.py index 90088772..53c39121 100644 --- a/backend/contacts/import_views.py +++ b/backend/contacts/import_views.py @@ -16,7 +16,6 @@ from rest_framework.views import APIView from common.permissions import HasOrgContext from contacts.services.csv_import import commit_rows, parse_and_validate - MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 5 MB; matches the UI hint diff --git a/backend/contacts/migrations/0011_inbound_email.py b/backend/contacts/migrations/0011_inbound_email.py index a7b0848a..ea54c1f6 100644 --- a/backend/contacts/migrations/0011_inbound_email.py +++ b/backend/contacts/migrations/0011_inbound_email.py @@ -4,15 +4,17 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('contacts', '0010_add_enterprise_constraints'), + ("contacts", "0010_add_enterprise_constraints"), ] operations = [ migrations.AddField( - model_name='contact', - name='auto_created', - field=models.BooleanField(default=False, help_text='True when the contact was created by an inbound channel (e.g. email-to-ticket) rather than a human; flagged for admin review.'), + model_name="contact", + name="auto_created", + field=models.BooleanField( + default=False, + help_text="True when the contact was created by an inbound channel (e.g. email-to-ticket) rather than a human; flagged for admin review.", + ), ), ] diff --git a/backend/contacts/migrations/0012_contact_custom_fields.py b/backend/contacts/migrations/0012_contact_custom_fields.py index f6178d7d..74c87fd9 100644 --- a/backend/contacts/migrations/0012_contact_custom_fields.py +++ b/backend/contacts/migrations/0012_contact_custom_fields.py @@ -2,15 +2,18 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('contacts', '0011_inbound_email'), + ("contacts", "0011_inbound_email"), ] operations = [ migrations.AddField( - model_name='contact', - name='custom_fields', - field=models.JSONField(blank=True, default=dict, help_text='Per-org schema extension; values are validated against common.CustomFieldDefinition.'), + model_name="contact", + name="custom_fields", + field=models.JSONField( + blank=True, + default=dict, + help_text="Per-org schema extension; values are validated against common.CustomFieldDefinition.", + ), ), ] diff --git a/backend/contacts/migrations/0013_contact_is_sample.py b/backend/contacts/migrations/0013_contact_is_sample.py index 9411778b..a619a0f1 100644 --- a/backend/contacts/migrations/0013_contact_is_sample.py +++ b/backend/contacts/migrations/0013_contact_is_sample.py @@ -4,15 +4,17 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('contacts', '0012_contact_custom_fields'), + ("contacts", "0012_contact_custom_fields"), ] operations = [ migrations.AddField( - model_name='contact', - name='is_sample', - field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + model_name="contact", + name="is_sample", + field=models.BooleanField( + default=False, + help_text="True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.", + ), ), ] diff --git a/backend/contacts/serializer.py b/backend/contacts/serializer.py index 1c77a49f..11adec80 100644 --- a/backend/contacts/serializer.py +++ b/backend/contacts/serializer.py @@ -9,7 +9,6 @@ from common.serializer import ( ) from contacts.models import Contact - # Note: Removed unused serializer properties that were computed but never used by frontend: # - get_team_users, get_team_and_assigned_users, get_assigned_users_not_in_teams # - created_on_arrow (frontend computes its own humanized timestamps) diff --git a/backend/contacts/services/csv_import.py b/backend/contacts/services/csv_import.py index 366b3d3e..7f721e5b 100644 --- a/backend/contacts/services/csv_import.py +++ b/backend/contacts/services/csv_import.py @@ -43,7 +43,6 @@ from common.validators import normalize_phone from contacts.models import Contact from contacts.services.account_link import link_primary_account - REQUIRED_HEADERS = ("first_name", "last_name") OPTIONAL_HEADERS = ( "email", @@ -257,7 +256,10 @@ def parse_and_validate(file_bytes: bytes, org) -> ImportResult: for idx, raw_row in enumerate(data_rows, start=1): if not any((cell or "").strip() for cell in raw_row): continue - record = {h: (raw_row[i].strip() if i < len(raw_row) else "") for i, h in enumerate(headers)} + record = { + h: (raw_row[i].strip() if i < len(raw_row) else "") + for i, h in enumerate(headers) + } parsed.append((idx, record)) ref_maps = _build_ref_maps(parsed, org) @@ -373,9 +375,12 @@ def _build_ref_maps(parsed: list[tuple[int, dict[str, str]]], org) -> _RefMaps: # Phone has no DB constraint and no normalized column; we have to scan # all contacts that have a phone in this org and normalize in Python. # Bounded by org size, not file size, fine for any reasonable tenant. - for raw_phone in Contact.objects.filter(org=org).exclude( - phone__isnull=True - ).exclude(phone="").values_list("phone", flat=True): + for raw_phone in ( + Contact.objects.filter(org=org) + .exclude(phone__isnull=True) + .exclude(phone="") + .values_list("phone", flat=True) + ): normalized = normalize_phone(raw_phone) if normalized and normalized in candidate_phones: existing_phones.add(normalized) @@ -540,9 +545,7 @@ def _validate_and_build( do_not_call_raw = record.get("do_not_call", "") do_not_call = _parse_bool(do_not_call_raw) if do_not_call is None: - errors.append( - RowError(idx, "do_not_call", "Use yes/no, true/false, or 1/0") - ) + errors.append(RowError(idx, "do_not_call", "Use yes/no, true/false, or 1/0")) do_not_call = False account_id: str | None = None @@ -581,9 +584,7 @@ def _validate_and_build( for team_name in _split_multi(record.get("team_names", "")): resolved = refs.teams.get(team_name.lower()) if resolved is None: - errors.append( - RowError(idx, "team_names", f"No team named '{team_name}'") - ) + errors.append(RowError(idx, "team_names", f"No team named '{team_name}'")) else: team_ids.append(resolved) @@ -711,7 +712,9 @@ def _commit_validated(rows: list[ValidatedRow], org, profile) -> dict[str, Any]: if vr.team_ids: contact.teams.set(vr.team_ids) if vr.tag_names: - tag_objs = [_get_or_create_tag(name, org, tag_cache) for name in vr.tag_names] + tag_objs = [ + _get_or_create_tag(name, org, tag_cache) for name in vr.tag_names + ] contact.tags.set(tag_objs) created_ids.append(str(contact.id)) diff --git a/backend/contacts/tests/test_contacts_api.py b/backend/contacts/tests/test_contacts_api.py index 5a9b0842..475b2afa 100644 --- a/backend/contacts/tests/test_contacts_api.py +++ b/backend/contacts/tests/test_contacts_api.py @@ -26,7 +26,6 @@ from rest_framework import status from common.models import Attachments, Comment, Tags, Teams from contacts.models import Contact - CONTACTS_LIST_URL = "/api/contacts/" diff --git a/backend/contacts/tests/test_custom_fields.py b/backend/contacts/tests/test_custom_fields.py index 491007e8..72dab956 100644 --- a/backend/contacts/tests/test_custom_fields.py +++ b/backend/contacts/tests/test_custom_fields.py @@ -26,9 +26,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) def _make_severity_def(org, **overrides): @@ -220,9 +218,7 @@ class TestContactDetailResponse: keys = [d["key"] for d in body["custom_field_definitions"]] assert "severity" in keys - def test_detail_excludes_inactive_definitions( - self, admin_client, contact_a, org_a - ): + def test_detail_excludes_inactive_definitions(self, admin_client, contact_a, org_a): _make_severity_def(org_a) _make_severity_def( org_a, key="legacy", field_type="text", options=None, is_active=False diff --git a/backend/contacts/tests/test_import.py b/backend/contacts/tests/test_import.py index acea6b0f..ece1319b 100644 --- a/backend/contacts/tests/test_import.py +++ b/backend/contacts/tests/test_import.py @@ -60,7 +60,16 @@ class TestImportPreview: def test_happy_path(self, admin_client, org_a, account_a, admin_profile): csv_file = _csv( ["first_name", "last_name", "email", "phone", "account_name", "tags"], - [["Alice", "Smith", "alice@example.com", "+1 555 111 2222", "Acme Corp", "vip;newsletter"]], + [ + [ + "Alice", + "Smith", + "alice@example.com", + "+1 555 111 2222", + "Acme Corp", + "vip;newsletter", + ] + ], ) response = admin_client.post( "/api/contacts/import/preview/", {"file": csv_file}, format="multipart" @@ -97,9 +106,7 @@ class TestImportPreview: body = response.json() assert body["header_error"] and "totally_made_up" in body["header_error"] - def test_email_duplicate_vs_db( - self, admin_client, existing_contact, admin_profile - ): + def test_email_duplicate_vs_db(self, admin_client, existing_contact, admin_profile): csv_file = _csv( ["first_name", "last_name", "email"], [["Different", "Person", "pat@acme.test"]], @@ -210,9 +217,7 @@ class TestImportPreview: body = response.json() assert body["summary"] == {"total": 1, "valid": 1, "invalid": 0} - def test_full_name_dup_within_file_blocked( - self, admin_client, admin_profile - ): + def test_full_name_dup_within_file_blocked(self, admin_client, admin_profile): # Two name-only rows with no email/phone. Second row must be flagged. csv_file = _csv( ["first_name", "last_name"], @@ -231,9 +236,7 @@ class TestImportPreview: assert err["row"] == 2 and err["field"] == "first_name" assert "row 1" in err["message"] - def test_linkedin_url_with_only_scheme_rejected( - self, admin_client, admin_profile - ): + def test_linkedin_url_with_only_scheme_rejected(self, admin_client, admin_profile): # "https://" passes the old regex but is not a valid URL, Django's # URLValidator catches it. csv_file = _csv( @@ -318,9 +321,7 @@ class TestImportPreview: body = response.json() assert any(e["field"] == "assigned_emails" for e in body["errors"]) - def test_permission_denied_for_regular_user( - self, user_client, user_profile - ): + def test_permission_denied_for_regular_user(self, user_client, user_profile): csv_file = _csv( ["first_name", "last_name"], [["A", "B"]], @@ -348,10 +349,34 @@ class TestImportCommit: self, admin_client, org_a, account_a, team_a, admin_profile ): csv_file = _csv( - ["first_name", "last_name", "email", "phone", "account_name", "team_names", "tags"], [ - ["Alice", "Smith", "alice@example.com", "+1 555 111 2222", "Acme Corp", "Sales", "vip"], - ["Bob", "Jones", "bob@example.com", "+1 555 333 4444", "Acme Corp", "Sales", "vip;newsletter"], + "first_name", + "last_name", + "email", + "phone", + "account_name", + "team_names", + "tags", + ], + [ + [ + "Alice", + "Smith", + "alice@example.com", + "+1 555 111 2222", + "Acme Corp", + "Sales", + "vip", + ], + [ + "Bob", + "Jones", + "bob@example.com", + "+1 555 333 4444", + "Acme Corp", + "Sales", + "vip;newsletter", + ], ], ) response = admin_client.post( diff --git a/backend/contacts/views.py b/backend/contacts/views.py index e8368862..1995464b 100644 --- a/backend/contacts/views.py +++ b/backend/contacts/views.py @@ -14,8 +14,6 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response - -from common.permissions import HasOrgContext, is_org_admin from rest_framework.views import APIView from common.custom_fields import validate_payload as validate_custom_fields_payload @@ -27,6 +25,7 @@ from common.models import ( Tags, Teams, ) +from common.permissions import HasOrgContext, is_org_admin from common.serializer import ( AttachmentsSerializer, CommentSerializer, @@ -89,9 +88,7 @@ class ContactsListView(APIView, LimitOffsetPagination): # string, which Django iterates character by character -- each # character then failed to parse as a UUID, so filtering by an # owner answered 500. - queryset = queryset.filter( - assigned_to__id__in=assigned_to - ).distinct() + queryset = queryset.filter(assigned_to__id__in=assigned_to).distinct() tags = uuid_list_param(params, "tags") if tags: queryset = queryset.filter(tags__id__in=tags).distinct() diff --git a/backend/crm/settings.py b/backend/crm/settings.py index 465a7c07..035c9a74 100644 --- a/backend/crm/settings.py +++ b/backend/crm/settings.py @@ -12,13 +12,17 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) load_dotenv() # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-key-please-change-in-production") +SECRET_KEY = os.environ.get( + "SECRET_KEY", "django-insecure-dev-key-please-change-in-production" +) IS_DEV_ENV = os.environ.get("ENV_TYPE", "dev") == "dev" if not SECRET_KEY or SECRET_KEY.startswith("django-insecure"): if not IS_DEV_ENV: - raise ValueError("SECRET_KEY must be set to a secure value in non-dev environments") + raise ValueError( + "SECRET_KEY must be set to a secure value in non-dev environments" + ) # This key also signs every JWT (see SIMPLE_JWT["SIGNING_KEY"] below). RFC 7518 # section 3.2 requires an HMAC key at least as long as the hash it produces, so @@ -34,7 +38,7 @@ if len(SECRET_KEY.encode()) < JWT_MIN_SIGNING_KEY_BYTES: message = ( f"SECRET_KEY is {len(SECRET_KEY.encode())} bytes. It signs every JWT, " f"and HS256 needs at least {JWT_MIN_SIGNING_KEY_BYTES}. Generate one with " - "`python -c \"import secrets; print(secrets.token_urlsafe(48))\"`." + '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.' ) if not IS_DEV_ENV: raise ValueError(message) @@ -245,7 +249,9 @@ if "django_ses" in EMAIL_BACKEND: # celery Tasks CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0") -CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0") +CELERY_RESULT_BACKEND = os.environ.get( + "CELERY_RESULT_BACKEND", "redis://localhost:6379/0" +) LOGGING = { diff --git a/backend/invoices/api_views.py b/backend/invoices/api_views.py index 74dec4ae..6f5987ff 100644 --- a/backend/invoices/api_views.py +++ b/backend/invoices/api_views.py @@ -20,13 +20,13 @@ from rest_framework.views import APIView from common.custom_fields import validate_payload as validate_custom_fields_payload from common.models import Attachments, Comment, CustomFieldDefinition from common.permissions import HasOrgContext, is_org_admin -from common.utils import create_attachment -from common.validators import uuid_param from common.serializer import ( AttachmentsSerializer, CommentSerializer, CustomFieldDefinitionSerializer, ) +from common.utils import create_attachment +from common.validators import uuid_param from invoices.models import ( UNPAID_STATUSES, Estimate, @@ -2034,7 +2034,7 @@ class RevenueReportView(APIView): group_by = request.GET.get("group_by", "month") # day, week, month, year if not start_date: - start_date = (timezone.localdate() - timedelta(days=365)) + start_date = timezone.localdate() - timedelta(days=365) else: start_date = datetime.datetime.strptime(start_date, "%Y-%m-%d").date() diff --git a/backend/invoices/migrations/0004_estimate_estimatelineitem_invoicetemplate_payment_and_more.py b/backend/invoices/migrations/0004_estimate_estimatelineitem_invoicetemplate_payment_and_more.py index bfddc78c..68893280 100644 --- a/backend/invoices/migrations/0004_estimate_estimatelineitem_invoicetemplate_payment_and_more.py +++ b/backend/invoices/migrations/0004_estimate_estimatelineitem_invoicetemplate_payment_and_more.py @@ -1,8 +1,9 @@ # Generated by Django 5.2.8 on 2025-12-08 03:38 import datetime -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models diff --git a/backend/invoices/migrations/0010_estimate_accepted_by_email_estimate_accepted_by_name_and_more.py b/backend/invoices/migrations/0010_estimate_accepted_by_email_estimate_accepted_by_name_and_more.py index f06948ad..33941f96 100644 --- a/backend/invoices/migrations/0010_estimate_accepted_by_email_estimate_accepted_by_name_and_more.py +++ b/backend/invoices/migrations/0010_estimate_accepted_by_email_estimate_accepted_by_name_and_more.py @@ -4,30 +4,37 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('invoices', '0009_estimate_recurring_custom_fields'), + ("invoices", "0009_estimate_recurring_custom_fields"), ] operations = [ migrations.AddField( - model_name='estimate', - name='accepted_by_email', - field=models.EmailField(blank=True, default='', max_length=254, verbose_name='Accepted By Email'), + model_name="estimate", + name="accepted_by_email", + field=models.EmailField( + blank=True, default="", max_length=254, verbose_name="Accepted By Email" + ), ), migrations.AddField( - model_name='estimate', - name='accepted_by_name', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='Accepted By Name'), + model_name="estimate", + name="accepted_by_name", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="Accepted By Name" + ), ), migrations.AddField( - model_name='estimate', - name='accepted_ip', - field=models.GenericIPAddressField(blank=True, null=True, verbose_name='Accepted From IP'), + model_name="estimate", + name="accepted_ip", + field=models.GenericIPAddressField( + blank=True, null=True, verbose_name="Accepted From IP" + ), ), migrations.AddField( - model_name='estimate', - name='accepted_user_agent', - field=models.TextField(blank=True, default='', verbose_name='Accepted User Agent'), + model_name="estimate", + name="accepted_user_agent", + field=models.TextField( + blank=True, default="", verbose_name="Accepted User Agent" + ), ), ] diff --git a/backend/invoices/models.py b/backend/invoices/models.py index bf3c8e07..a3f58703 100644 --- a/backend/invoices/models.py +++ b/backend/invoices/models.py @@ -412,9 +412,8 @@ class Invoice(AssignableMixin, BaseModel): def generate_invoice_number(self): """Generate unique invoice number: INV-YYYYMMDD-XXXX""" from django.db import transaction - from django.db.models import Max + from django.db.models import IntegerField, Max from django.db.models.functions import Cast, Substr - from django.db.models import IntegerField date_str = datetime.datetime.now().strftime("%Y%m%d") prefix = f"INV-{date_str}-" @@ -870,9 +869,8 @@ class Estimate(AssignableMixin, BaseModel): def generate_estimate_number(self): """Generate unique estimate number: EST-YYYYMMDD-XXXX""" from django.db import transaction - from django.db.models import Max + from django.db.models import IntegerField, Max from django.db.models.functions import Cast, Substr - from django.db.models import IntegerField date_str = datetime.datetime.now().strftime("%Y%m%d") prefix = f"EST-{date_str}-" @@ -1125,6 +1123,7 @@ class RecurringInvoice(AssignableMixin, BaseModel): def calculate_next_date(self): """Calculate the next generation date based on frequency""" from datetime import timedelta + from dateutil.relativedelta import relativedelta current = self.next_generation_date diff --git a/backend/invoices/permissions.py b/backend/invoices/permissions.py index e35374a1..0c552d7d 100644 --- a/backend/invoices/permissions.py +++ b/backend/invoices/permissions.py @@ -22,7 +22,6 @@ from rest_framework import status from rest_framework.response import Response from common.permissions import is_org_admin - from invoices.models import Estimate, Invoice, RecurringInvoice INVOICE_NOT_FOUND = "Invoice not found" diff --git a/backend/invoices/seed.py b/backend/invoices/seed.py index a432a388..9fafae68 100644 --- a/backend/invoices/seed.py +++ b/backend/invoices/seed.py @@ -826,16 +826,16 @@ class InvoiceSeeder: def clear_invoice_data(self): """Clear all invoice-related data in reverse dependency order.""" from invoices.models import ( - Payment, + Estimate, + EstimateLineItem, + Invoice, InvoiceHistory, InvoiceLineItem, - Invoice, - EstimateLineItem, - Estimate, - RecurringInvoiceLineItem, - RecurringInvoice, - Product, InvoiceTemplate, + Payment, + Product, + RecurringInvoice, + RecurringInvoiceLineItem, ) # Delete in reverse dependency order diff --git a/backend/invoices/serializer.py b/backend/invoices/serializer.py index e35a36f3..e4f54e9a 100644 --- a/backend/invoices/serializer.py +++ b/backend/invoices/serializer.py @@ -383,8 +383,7 @@ def validate_line_item_products(line_items, org): raise serializers.ValidationError( { "line_items": ( - "A line item references a product from another " - "organization." + "A line item references a product from another organization." ) } ) diff --git a/backend/invoices/tasks.py b/backend/invoices/tasks.py index f4c5beb6..a5cc4d72 100644 --- a/backend/invoices/tasks.py +++ b/backend/invoices/tasks.py @@ -308,7 +308,9 @@ def generate_recurring_invoices(): recurring.invoices_generated += 1 recurring.save() - logger.info("Created invoice %s from recurring %s", invoice.id, recurring.id) + logger.info( + "Created invoice %s from recurring %s", invoice.id, recurring.id + ) # Auto-send if enabled if recurring.auto_send: @@ -404,7 +406,9 @@ def send_payment_reminder(invoice_id, org_id, domain="localhost", protocol="http # Don't send reminders for paid or cancelled invoices if invoice.status in ["Paid", "Cancelled"]: - logger.info("Skipping reminder for %s - status is %s", invoice_id, invoice.status) + logger.info( + "Skipping reminder for %s - status is %s", invoice_id, invoice.status + ) return subject = f"Payment Reminder: Invoice #{invoice.invoice_number}" @@ -473,7 +477,9 @@ def process_payment_reminders(): # Check if we should send a reminder should_send = False - days_until_due = (invoice.due_date - today).days if invoice.due_date else None + days_until_due = ( + (invoice.due_date - today).days if invoice.due_date else None + ) # Determine reminder interval based on frequency setting reminder_interval_days = 7 # default @@ -514,7 +520,9 @@ def process_payment_reminders(): str(invoice.id), str(invoice.org.id), domain=getattr(settings, "DOMAIN_NAME", "localhost"), - protocol="https" if getattr(settings, "USE_HTTPS", False) else "http", + protocol="https" + if getattr(settings, "USE_HTTPS", False) + else "http", ) finally: # Worker threads are reused between tasks; leaving the last diff --git a/backend/invoices/tests/test_client_links.py b/backend/invoices/tests/test_client_links.py index c633c6fc..7e04a035 100644 --- a/backend/invoices/tests/test_client_links.py +++ b/backend/invoices/tests/test_client_links.py @@ -24,7 +24,10 @@ from common.links import frontend_url class TestFrontendUrl: @override_settings(FRONTEND_URL="https://app.example.com") def test_builds_absolute_url(self): - assert frontend_url("/portal/invoice/abc") == "https://app.example.com/portal/invoice/abc" + assert ( + frontend_url("/portal/invoice/abc") + == "https://app.example.com/portal/invoice/abc" + ) @override_settings(FRONTEND_URL="https://app.example.com/") def test_strips_a_trailing_slash_on_the_base(self): diff --git a/backend/invoices/tests/test_custom_fields.py b/backend/invoices/tests/test_custom_fields.py index d653f6d1..fc2416c7 100644 --- a/backend/invoices/tests/test_custom_fields.py +++ b/backend/invoices/tests/test_custom_fields.py @@ -27,9 +27,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) def _make_priority_def(org, **overrides): @@ -241,9 +239,7 @@ class TestInvoiceListFilter: cf_invoice_secondary.custom_fields = {"billing_priority": "standard"} cf_invoice_secondary.save() - response = admin_client.get( - f"{INVOICES_LIST_URL}?cf_billing_priority=rush" - ) + response = admin_client.get(f"{INVOICES_LIST_URL}?cf_billing_priority=rush") ids = [r["id"] for r in response.json()["results"]] assert str(cf_invoice.id) in ids assert str(cf_invoice_secondary.id) not in ids diff --git a/backend/invoices/tests/test_estimate_recurring_custom_fields.py b/backend/invoices/tests/test_estimate_recurring_custom_fields.py index 0106428e..a9950ef7 100644 --- a/backend/invoices/tests/test_estimate_recurring_custom_fields.py +++ b/backend/invoices/tests/test_estimate_recurring_custom_fields.py @@ -8,12 +8,12 @@ import datetime import pytest from django.db import connection +from django.utils import timezone from accounts.models import Account from common.models import CustomFieldDefinition from contacts.models import Contact from invoices.models import Estimate, RecurringInvoice -from django.utils import timezone pg_only = pytest.mark.skipif( connection.vendor != "postgresql", @@ -29,9 +29,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) def _make_def(org, target_model, **overrides): diff --git a/backend/invoices/tests/test_estimate_status_authz.py b/backend/invoices/tests/test_estimate_status_authz.py index b21ec700..6d06aedd 100644 --- a/backend/invoices/tests/test_estimate_status_authz.py +++ b/backend/invoices/tests/test_estimate_status_authz.py @@ -19,10 +19,10 @@ edit, and still works through the endpoint that guards it. import datetime import pytest +from django.utils import timezone from rest_framework import status from invoices.models import Estimate -from django.utils import timezone def _estimate(org, **kwargs): diff --git a/backend/invoices/tests/test_invoice_object_permissions.py b/backend/invoices/tests/test_invoice_object_permissions.py index 5aa7dcc8..94533819 100644 --- a/backend/invoices/tests/test_invoice_object_permissions.py +++ b/backend/invoices/tests/test_invoice_object_permissions.py @@ -34,9 +34,9 @@ def account_a(org_a): def _make_invoice(title, account, org, creator): """Build a 100.00 invoice owned by ``creator`` (a User). - ``created_by`` must be set with a queryset update: BaseModel.save() resets it - from the thread-local current user, which is None in fixtures. -from django.utils import timezone + ``created_by`` must be set with a queryset update: BaseModel.save() resets it + from the thread-local current user, which is None in fixtures. + from django.utils import timezone """ invoice = Invoice.objects.create( invoice_title=title, diff --git a/backend/invoices/tests/test_invoices_api.py b/backend/invoices/tests/test_invoices_api.py index 337604fb..f167471d 100644 --- a/backend/invoices/tests/test_invoices_api.py +++ b/backend/invoices/tests/test_invoices_api.py @@ -2671,8 +2671,7 @@ class TestRevenueReport: def test_revenue_report_with_dates(self, admin_client): response = admin_client.get( - "/api/invoices/reports/revenue/" - "?start_date=2025-01-01&end_date=2026-12-31" + "/api/invoices/reports/revenue/?start_date=2025-01-01&end_date=2026-12-31" ) assert response.status_code == 200 diff --git a/backend/invoices/tests/test_line_item_product_org.py b/backend/invoices/tests/test_line_item_product_org.py index d5a5cc8e..1689bcfb 100644 --- a/backend/invoices/tests/test_line_item_product_org.py +++ b/backend/invoices/tests/test_line_item_product_org.py @@ -20,12 +20,12 @@ the first half of that pair and breaks the feature. import datetime import pytest +from django.utils import timezone from rest_framework import status from accounts.models import Account from contacts.models import Contact from invoices.models import Invoice, InvoiceLineItem, Product -from django.utils import timezone INVOICES_URL = "/api/invoices/" ESTIMATES_URL = "/api/invoices/estimates/" @@ -135,8 +135,7 @@ class TestNestedCreateRefusesAForeignProduct: format="json", ) assert response.status_code == status.HTTP_400_BAD_REQUEST, ( - "a recurring invoice accepted a line item pointing at another " - "org's product" + "a recurring invoice accepted a line item pointing at another org's product" ) assert "line_items" in response.json()["errors"] diff --git a/backend/invoices/tests/test_portal_rls.py b/backend/invoices/tests/test_portal_rls.py index 2537acf3..bf6b210b 100644 --- a/backend/invoices/tests/test_portal_rls.py +++ b/backend/invoices/tests/test_portal_rls.py @@ -9,15 +9,14 @@ work if it resolves the org from the unscoped lookup first. See docs/PORTAL_RLS.md and [[client-portal-403]]. """ - import pytest from django.db import connection +from django.utils import timezone from common.models import Org from common.portal_tokens import resolve_portal_org from common.tasks import set_rls_context from invoices.models import Estimate -from django.utils import timezone pytestmark = [pytest.mark.postgres_only, pytest.mark.django_db] diff --git a/backend/invoices/tests/test_template_editor.py b/backend/invoices/tests/test_template_editor.py index 7c998b34..a869c98f 100644 --- a/backend/invoices/tests/test_template_editor.py +++ b/backend/invoices/tests/test_template_editor.py @@ -93,9 +93,7 @@ class TestOnlyAdminsAndOnlyThisOrg: assert response.status_code == status.HTTP_403_FORBIDDEN assert "template_html" not in response.content.decode() - def test_a_member_is_refused_before_the_id_is_looked_up( - self, user_client, org_a - ): + def test_a_member_is_refused_before_the_id_is_looked_up(self, user_client, org_a): """A 404 here would tell a member which template ids exist.""" missing = InvoiceTemplate(id="00000000-0000-0000-0000-000000000000") response = user_client.get(_editor_url(missing)) @@ -124,7 +122,11 @@ class TestTheEditRoundTrip: response = admin_client.put( f"/api/invoices/templates/{template.id}/", - {k: v for k, v in loaded.items() if k not in ("id", "created_at", "updated_at")}, + { + k: v + for k, v in loaded.items() + if k not in ("id", "created_at", "updated_at") + }, format="json", ) assert response.status_code == status.HTTP_200_OK, response.content diff --git a/backend/leads/forms.py b/backend/leads/forms.py index fa843728..461ae5d4 100644 --- a/backend/leads/forms.py +++ b/backend/leads/forms.py @@ -91,7 +91,5 @@ class LeadListForm(forms.Form): self.validated_rows = data.get("validated_rows", []) self.invalid_rows = data.get("invalid_rows", []) if len(self.validated_rows) == 0: - raise forms.ValidationError( - "All the leads in the file are invalid." - ) + raise forms.ValidationError("All the leads in the file are invalid.") return document diff --git a/backend/leads/migrations/0011_add_enterprise_constraints.py b/backend/leads/migrations/0011_add_enterprise_constraints.py index 516586ce..6a8d16e0 100644 --- a/backend/leads/migrations/0011_add_enterprise_constraints.py +++ b/backend/leads/migrations/0011_add_enterprise_constraints.py @@ -5,7 +5,7 @@ import django.db.models.functions.text from django.conf import settings from django.db import migrations, models from django.db.models import Count -from django.db.models.functions import Lower, Length +from django.db.models.functions import Length, Lower def cleanup_duplicate_lead_emails(apps, schema_editor): diff --git a/backend/leads/migrations/0012_lead_kanban_pipeline.py b/backend/leads/migrations/0012_lead_kanban_pipeline.py index 817cc6d1..b4a47a01 100644 --- a/backend/leads/migrations/0012_lead_kanban_pipeline.py +++ b/backend/leads/migrations/0012_lead_kanban_pipeline.py @@ -1,7 +1,8 @@ # Generated by Django 5.2.9 on 2025-12-09 08:33 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models diff --git a/backend/leads/migrations/0013_lead_custom_fields.py b/backend/leads/migrations/0013_lead_custom_fields.py index 1c3ac48c..fadd787d 100644 --- a/backend/leads/migrations/0013_lead_custom_fields.py +++ b/backend/leads/migrations/0013_lead_custom_fields.py @@ -2,15 +2,18 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('leads', '0012_lead_kanban_pipeline'), + ("leads", "0012_lead_kanban_pipeline"), ] operations = [ migrations.AddField( - model_name='lead', - name='custom_fields', - field=models.JSONField(blank=True, default=dict, help_text='Per-org schema extension; values are validated against common.CustomFieldDefinition.'), + model_name="lead", + name="custom_fields", + field=models.JSONField( + blank=True, + default=dict, + help_text="Per-org schema extension; values are validated against common.CustomFieldDefinition.", + ), ), ] diff --git a/backend/leads/migrations/0014_lead_is_sample.py b/backend/leads/migrations/0014_lead_is_sample.py index 5559b137..1fa776ea 100644 --- a/backend/leads/migrations/0014_lead_is_sample.py +++ b/backend/leads/migrations/0014_lead_is_sample.py @@ -2,15 +2,17 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('leads', '0013_lead_custom_fields'), + ("leads", "0013_lead_custom_fields"), ] operations = [ migrations.AddField( - model_name='lead', - name='is_sample', - field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real lead as sample and have it deleted.'), + model_name="lead", + name="is_sample", + field=models.BooleanField( + default=False, + help_text="True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real lead as sample and have it deleted.", + ), ), ] diff --git a/backend/leads/migrations/0015_alter_lead_is_sample.py b/backend/leads/migrations/0015_alter_lead_is_sample.py index 08c69bc9..f0906c04 100644 --- a/backend/leads/migrations/0015_alter_lead_is_sample.py +++ b/backend/leads/migrations/0015_alter_lead_is_sample.py @@ -4,15 +4,17 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('leads', '0014_lead_is_sample'), + ("leads", "0014_lead_is_sample"), ] operations = [ migrations.AlterField( - model_name='lead', - name='is_sample', - field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + model_name="lead", + name="is_sample", + field=models.BooleanField( + default=False, + help_text="True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.", + ), ), ] diff --git a/backend/leads/migrations/0016_alter_leadpipeline_is_default.py b/backend/leads/migrations/0016_alter_leadpipeline_is_default.py index d32b1b0a..41f1d4f9 100644 --- a/backend/leads/migrations/0016_alter_leadpipeline_is_default.py +++ b/backend/leads/migrations/0016_alter_leadpipeline_is_default.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("leads", "0015_alter_lead_is_sample"), ] diff --git a/backend/leads/models.py b/backend/leads/models.py index b22658d0..b0d33d8a 100644 --- a/backend/leads/models.py +++ b/backend/leads/models.py @@ -17,7 +17,6 @@ from common.utils import ( from common.validators import flexible_phone_validator from contacts.models import Contact - # Cleanup notes: # - Removed 'created_from_site' flag (over-engineered) # - Removed conversion tracking fields (converted_account, converted_contact, @@ -174,7 +173,8 @@ class Lead(AssignableMixin, BaseModel): ), # Opportunity amount must be non-negative models.CheckConstraint( - condition=Q(opportunity_amount__gte=0) | Q(opportunity_amount__isnull=True), + condition=Q(opportunity_amount__gte=0) + | Q(opportunity_amount__isnull=True), name="lead_amount_non_negative", ), ] diff --git a/backend/leads/tasks.py b/backend/leads/tasks.py index 45aab155..8d033587 100644 --- a/backend/leads/tasks.py +++ b/backend/leads/tasks.py @@ -8,14 +8,14 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives from django.db.models import Q from django.template.loader import render_to_string -logger = logging.getLogger(__name__) - from accounts.models import Account from common.links import frontend_url from common.models import Org, Profile from common.tasks import set_rls_context from leads.models import Lead +logger = logging.getLogger(__name__) + def get_rendered_html(template_name, context=None): if context is None: diff --git a/backend/leads/tests/test_attachment_delete_authz.py b/backend/leads/tests/test_attachment_delete_authz.py index 4a7c1588..d44bde0f 100644 --- a/backend/leads/tests/test_attachment_delete_authz.py +++ b/backend/leads/tests/test_attachment_delete_authz.py @@ -55,7 +55,9 @@ def lead_b(org_b): class TestLeadAttachmentDelete: URL = "/api/leads/attachment/{}/" - def test_admin_deletes_own_org_attachment(self, org_a, admin_client, admin_user, lead_a): + def test_admin_deletes_own_org_attachment( + self, org_a, admin_client, admin_user, lead_a + ): att = _attachment(org_a, lead_a, admin_user) assert admin_client.delete(self.URL.format(att.id)).status_code == 200 assert not Attachments.objects.filter(id=att.id).exists() @@ -68,9 +70,7 @@ class TestLeadAttachmentDelete: assert user_client.delete(self.URL.format(att.id)).status_code == 200 assert not Attachments.objects.filter(id=att.id).exists() - def test_other_member_cannot_delete( - self, org_a, user_client, admin_user, lead_a - ): + def test_other_member_cannot_delete(self, org_a, user_client, admin_user, lead_a): """The same check must be able to return False, or it is not a check.""" att = _attachment(org_a, lead_a, admin_user) assert user_client.delete(self.URL.format(att.id)).status_code == 403 diff --git a/backend/leads/tests/test_custom_fields.py b/backend/leads/tests/test_custom_fields.py index e90a1ef4..a094c94f 100644 --- a/backend/leads/tests/test_custom_fields.py +++ b/backend/leads/tests/test_custom_fields.py @@ -26,9 +26,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) def _make_severity_def(org, **overrides): @@ -165,9 +163,7 @@ class TestLeadUpdateWithCustomFields: ) assert response.status_code == 400 - def test_patch_converted_persists_custom_fields( - self, admin_client, lead_a, org_a - ): + def test_patch_converted_persists_custom_fields(self, admin_client, lead_a, org_a): """Regression for the silent-drop bug: PATCH with both status=converted and custom_fields used to discard the fields because the conversion branch returned before the validate/save block.""" @@ -202,9 +198,7 @@ class TestLeadUpdateWithCustomFields: @pg_only @pytest.mark.django_db class TestLeadListFilter: - def test_cf_filter_returns_matching( - self, admin_client, lead_a, lead_a2, org_a - ): + def test_cf_filter_returns_matching(self, admin_client, lead_a, lead_a2, org_a): _make_severity_def(org_a) lead_a.custom_fields = {"severity": "S1"} lead_a.save() @@ -220,9 +214,7 @@ class TestLeadListFilter: @pytest.mark.django_db class TestLeadDetailResponse: - def test_detail_includes_definitions_and_values( - self, admin_client, lead_a, org_a - ): + def test_detail_includes_definitions_and_values(self, admin_client, lead_a, org_a): _make_severity_def(org_a) lead_a.custom_fields = {"severity": "S2"} lead_a.save() @@ -234,9 +226,7 @@ class TestLeadDetailResponse: keys = [d["key"] for d in body["custom_field_definitions"]] assert "severity" in keys - def test_detail_excludes_inactive_definitions( - self, admin_client, lead_a, org_a - ): + def test_detail_excludes_inactive_definitions(self, admin_client, lead_a, org_a): _make_severity_def(org_a) _make_severity_def( org_a, key="legacy", field_type="text", options=None, is_active=False diff --git a/backend/leads/tests/test_lead_access_and_totals.py b/backend/leads/tests/test_lead_access_and_totals.py index ae365072..70b51359 100644 --- a/backend/leads/tests/test_lead_access_and_totals.py +++ b/backend/leads/tests/test_lead_access_and_totals.py @@ -86,13 +86,15 @@ class TestLeadTotals: assert len(body["open_leads"]["open_leads"]) == 10 assert body["totals"]["count"] == 24 - def test_unworked_counts_leads_nobody_touched_in_a_week( - self, admin_client, org_a - ): + def test_unworked_counts_leads_nobody_touched_in_a_week(self, admin_client, org_a): """Contacted 8 days ago counts; contacted yesterday does not.""" today = timezone.localdate() - _lead(org_a, email="stale@example.com", last_contacted=today - timedelta(days=8)) - _lead(org_a, email="fresh@example.com", last_contacted=today - timedelta(days=1)) + _lead( + org_a, email="stale@example.com", last_contacted=today - timedelta(days=8) + ) + _lead( + org_a, email="fresh@example.com", last_contacted=today - timedelta(days=1) + ) totals = admin_client.get(LEADS_URL).json()["totals"] @@ -173,9 +175,7 @@ class TestLeadDetailRead: assert user_client.get(_detail_url(lead.id)).status_code == 200 - def test_creator_can_read_their_own_lead( - self, user_client, org_a, regular_user - ): + def test_creator_can_read_their_own_lead(self, user_client, org_a, regular_user): """The branch that was broken by the type mismatch. `created_by` is a User FK, so it has to be compared against `profile.user`, and the id added to the allow-list has to be the *Profile* id.""" @@ -298,9 +298,7 @@ class TestPutClearsRelations: makes PUT partial should have to notice this test. """ - def test_put_without_tags_clears_them( - self, admin_client, org_a, admin_profile - ): + def test_put_without_tags_clears_them(self, admin_client, org_a, admin_profile): lead = _lead(org_a) tag = Tags.objects.create(name="Enterprise", org=org_a) lead.tags.add(tag) @@ -314,9 +312,7 @@ class TestPutClearsRelations: assert lead.tags.count() == 0 assert lead.assigned_to.count() == 0 - def test_patch_without_tags_leaves_them( - self, admin_client, org_a, admin_profile - ): + def test_patch_without_tags_leaves_them(self, admin_client, org_a, admin_profile): """The same edit over PATCH keeps everything it did not mention.""" lead = _lead(org_a) tag = Tags.objects.create(name="Enterprise", org=org_a) @@ -368,9 +364,7 @@ class TestLeadDetailWrite: lead.refresh_from_db() assert lead.first_name == "Augusta" - def test_creator_can_edit_their_own_lead( - self, user_client, org_a, regular_user - ): + def test_creator_can_edit_their_own_lead(self, user_client, org_a, regular_user): lead = _created_by(_lead(org_a), regular_user) response = user_client.put( diff --git a/backend/leads/tests/test_lead_status_transitions.py b/backend/leads/tests/test_lead_status_transitions.py index b017cb1c..4f09f948 100644 --- a/backend/leads/tests/test_lead_status_transitions.py +++ b/backend/leads/tests/test_lead_status_transitions.py @@ -102,9 +102,7 @@ class TestConvertedIsIrreversible: Opportunity.objects.filter(org=org_a).count() == opportunities_after_first ) - def test_moving_out_of_converted_over_patch_is_rejected( - self, admin_client, org_a - ): + def test_moving_out_of_converted_over_patch_is_rejected(self, admin_client, org_a): """PATCH's ordinary path does run the serializer, so this direction was already covered. Asserted because the guard above sits in front of it.""" lead = _lead(org_a, status="converted") diff --git a/backend/leads/tests/test_lead_upload_authz.py b/backend/leads/tests/test_lead_upload_authz.py index 8fd391c3..229216ef 100644 --- a/backend/leads/tests/test_lead_upload_authz.py +++ b/backend/leads/tests/test_lead_upload_authz.py @@ -67,9 +67,7 @@ class TestUploadRequiresImportRights: f"Lead {n},x{n}@example.com\n" for n in range(200_000) ) assert len(big.encode()) > 5 * 1024 * 1024 - response = admin_client.post( - URL, {"leads_file": _csv(big)}, format="multipart" - ) + response = admin_client.post(URL, {"leads_file": _csv(big)}, format="multipart") assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -128,9 +126,7 @@ class TestImportTaskHandlesRealFiles: create_lead_from_file(rows, [], admin_profile.id, "localhost", org_a.id) assert Lead.objects.filter(title="Shared title", org=org_a).count() == 1 - def test_a_title_already_in_this_org_is_still_skipped( - self, org_a, admin_profile - ): + def test_a_title_already_in_this_org_is_still_skipped(self, org_a, admin_profile): """The check itself is preserved, only its scope changed.""" Lead.objects.create(title="Existing", org=org_a) rows = [{"title": "Existing", "email": "ada@example.com"}] diff --git a/backend/leads/tests/test_leads_api.py b/backend/leads/tests/test_leads_api.py index aa7d4b80..ab059e59 100644 --- a/backend/leads/tests/test_leads_api.py +++ b/backend/leads/tests/test_leads_api.py @@ -10,7 +10,6 @@ from common.models import Attachments, Comment, Tags, Teams from contacts.models import Contact from leads.models import Lead - LEADS_LIST_URL = "/api/leads/" @@ -30,9 +29,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) @pytest.mark.django_db @@ -191,9 +188,7 @@ class TestLeadListView: mock_email.assert_called_once() @patch("leads.views.lead_views.send_email_to_assigned_user.delay") - def test_create_lead_with_teams( - self, mock_email, admin_client, admin_user, org_a - ): + def test_create_lead_with_teams(self, mock_email, admin_client, admin_user, org_a): """Creating a lead with teams associates them properly.""" _set_rls(org_a) team = Teams.objects.create(name="Sales Team", created_by=admin_user, org=org_a) @@ -327,10 +322,7 @@ class TestLeadListView: response = admin_client.get(LEADS_LIST_URL, {"tags": str(tag_vip.id)}) assert response.status_code == 200 - emails = { - lead["email"] - for lead in response.json()["open_leads"]["open_leads"] - } + emails = {lead["email"] for lead in response.json()["open_leads"]["open_leads"]} assert emails == {"tagged-vip@example.com"} def test_list_leads_with_assigned_to_filter( @@ -363,10 +355,7 @@ class TestLeadListView: LEADS_LIST_URL, {"assigned_to": str(admin_profile.id)} ) assert response.status_code == 200 - emails = { - lead["email"] - for lead in response.json()["open_leads"]["open_leads"] - } + emails = {lead["email"] for lead in response.json()["open_leads"]["open_leads"]} assert emails == {"adminassigned@example.com"} def test_list_leads_regular_user_sees_assigned( @@ -583,9 +572,7 @@ class TestLeadDetailView: mock_email.assert_called_once() @patch("leads.views.lead_views.send_email_to_assigned_user.delay") - def test_update_lead_with_teams( - self, mock_email, admin_client, admin_user, org_a - ): + def test_update_lead_with_teams(self, mock_email, admin_client, admin_user, org_a): """PUT with teams updates team associations.""" _set_rls(org_a) team = Teams.objects.create(name="Dev Team", created_by=admin_user, org=org_a) @@ -826,9 +813,7 @@ class TestLeadCommentView: assert "lead_obj" in data assert "comments" in data - def test_add_comment_creates_comment_object( - self, admin_client, admin_user, org_a - ): + def test_add_comment_creates_comment_object(self, admin_client, admin_user, org_a): """POSTing a comment actually creates a Comment object in the database.""" lead = Lead.objects.create( first_name="Comm", @@ -1163,7 +1148,9 @@ class TestLeadListViewFilters: response = admin_client.get(LEADS_LIST_URL, {"salutation": "Dr"}) assert response.status_code == 200 - def test_filter_by_assigned_to(self, admin_client, admin_user, admin_profile, org_a): + def test_filter_by_assigned_to( + self, admin_client, admin_user, admin_profile, org_a + ): """Filter by assigned_to returns only leads assigned to the given profile.""" lead = Lead.objects.create( first_name="AssignFilter", @@ -1177,10 +1164,7 @@ class TestLeadListViewFilters: LEADS_LIST_URL, {"assigned_to": str(admin_profile.id)} ) assert response.status_code == 200 - emails = { - lead["email"] - for lead in response.json()["open_leads"]["open_leads"] - } + emails = {lead["email"] for lead in response.json()["open_leads"]["open_leads"]} assert "assignfilter@example.com" in emails def test_filter_by_tags(self, admin_client, admin_user, org_a): @@ -1197,10 +1181,7 @@ class TestLeadListViewFilters: lead.tags.add(tag) response = admin_client.get(LEADS_LIST_URL, {"tags": str(tag.id)}) assert response.status_code == 200 - emails = { - lead["email"] - for lead in response.json()["open_leads"]["open_leads"] - } + emails = {lead["email"] for lead in response.json()["open_leads"]["open_leads"]} assert "tagfilter@example.com" in emails def test_filter_by_rating(self, admin_client, admin_user, org_a): @@ -1240,7 +1221,9 @@ class TestLeadCreateWithM2M: """Tests targeting uncovered create lines: contacts, attachments, teams, assigned_to, emails.""" @patch("leads.views.lead_views.send_email_to_assigned_user.delay") - def test_create_lead_with_contacts(self, mock_email, admin_client, admin_user, org_a): + def test_create_lead_with_contacts( + self, mock_email, admin_client, admin_user, org_a + ): """Creating a lead with contacts associates them (lines 242-246).""" _set_rls(org_a) contact = Contact.objects.create( @@ -1692,7 +1675,9 @@ class TestLeadPatchConversion: def test_patch_lead_with_teams(self, admin_client, admin_user, org_a): """PATCH with teams updates team associations (lines 825-839).""" _set_rls(org_a) - team = Teams.objects.create(name="PatchTeamLead", created_by=admin_user, org=org_a) + team = Teams.objects.create( + name="PatchTeamLead", created_by=admin_user, org=org_a + ) lead = Lead.objects.create( first_name="PatchTeamLead", last_name="Lead", @@ -1804,9 +1789,7 @@ class TestLeadCommentAttachmentOnDetail: ) assert response.status_code == 403 - def test_put_lead_org_mismatch( - self, org_b_client, admin_user, org_a, profile_b - ): + def test_put_lead_org_mismatch(self, org_b_client, admin_user, org_a, profile_b): """PUT from different org gets 403 (line 572).""" lead = Lead.objects.create( first_name="OrgMismatchPut", @@ -1817,15 +1800,17 @@ class TestLeadCommentAttachmentOnDetail: ) response = org_b_client.put( _detail_url(lead.id), - {"first_name": "Hacked", "last_name": "Lead", "email": "orgmismatchput@example.com"}, + { + "first_name": "Hacked", + "last_name": "Lead", + "email": "orgmismatchput@example.com", + }, format="json", ) # org_b_client has org_b in token but lead is in org_a - should get 404 from get_object assert response.status_code in (403, 404) - def test_patch_lead_org_mismatch( - self, org_b_client, admin_user, org_a, profile_b - ): + def test_patch_lead_org_mismatch(self, org_b_client, admin_user, org_a, profile_b): """PATCH from different org gets 404 (line 727).""" lead = Lead.objects.create( first_name="OrgMismatchPatch", @@ -2007,9 +1992,7 @@ class TestLeadModelProperties: from leads.models import LeadPipeline, LeadStage _set_rls(org_a) - pipeline = LeadPipeline.objects.create( - name="TestPipeline", org=org_a - ) + pipeline = LeadPipeline.objects.create(name="TestPipeline", org=org_a) stage = LeadStage.objects.create( pipeline=pipeline, name="Qualification", order=1, org=org_a ) @@ -2020,12 +2003,8 @@ class TestLeadModelProperties: from leads.models import LeadPipeline, LeadStage _set_rls(org_a) - pipeline = LeadPipeline.objects.create( - name="AutoOrgPipeline", org=org_a - ) - stage = LeadStage( - pipeline=pipeline, name="AutoOrg", order=1 - ) + pipeline = LeadPipeline.objects.create(name="AutoOrgPipeline", org=org_a) + stage = LeadStage(pipeline=pipeline, name="AutoOrg", order=1) stage.save() assert stage.org_id == org_a.id @@ -2034,7 +2013,5 @@ class TestLeadModelProperties: from leads.models import LeadPipeline _set_rls(org_a) - pipeline = LeadPipeline.objects.create( - name="MyPipeline", org=org_a - ) + pipeline = LeadPipeline.objects.create(name="MyPipeline", org=org_a) assert str(pipeline) == f"MyPipeline ({org_a.name})" diff --git a/backend/leads/tests/test_leads_kanban.py b/backend/leads/tests/test_leads_kanban.py index f7d5cbb3..8f5b5f8e 100644 --- a/backend/leads/tests/test_leads_kanban.py +++ b/backend/leads/tests/test_leads_kanban.py @@ -8,9 +8,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) @pytest.mark.django_db @@ -26,14 +24,10 @@ class TestLeadPipelineListCreateView: assert response.status_code == 201 data = response.json() assert data["name"] == "Sales Pipeline" - assert LeadPipeline.objects.filter( - name="Sales Pipeline", org=org_a - ).exists() + assert LeadPipeline.objects.filter(name="Sales Pipeline", org=org_a).exists() def test_list_pipelines(self, admin_client, admin_user, org_a): - LeadPipeline.objects.create( - name="Pipeline A", org=org_a, created_by=admin_user - ) + LeadPipeline.objects.create(name="Pipeline A", org=org_a, created_by=admin_user) response = admin_client.get("/api/leads/pipelines/") assert response.status_code == 200 data = response.json() @@ -109,9 +103,7 @@ class TestLeadPipelineDetailView: pipeline = LeadPipeline.objects.create( name="Empty Pipeline", org=org_a, created_by=admin_user ) - response = admin_client.delete( - f"/api/leads/pipelines/{pipeline.id}/" - ) + response = admin_client.delete(f"/api/leads/pipelines/{pipeline.id}/") assert response.status_code == 204 def test_delete_pipeline_with_leads_fails(self, admin_client, admin_user, org_a): @@ -358,9 +350,7 @@ class TestLeadStageViews: org=org_a, created_by=admin_user, ) - LeadStage.objects.create( - pipeline=pipeline, name="Only", order=0, org=org_a - ) + LeadStage.objects.create(pipeline=pipeline, name="Only", order=0, org=org_a) response = admin_client.post( f"/api/leads/pipelines/{pipeline.id}/stages/reorder/", {"stage_ids": ["00000000-0000-0000-0000-000000000001"]}, diff --git a/backend/leads/urls.py b/backend/leads/urls.py index d7df2577..24b52b46 100644 --- a/backend/leads/urls.py +++ b/backend/leads/urls.py @@ -1,5 +1,14 @@ from django.urls import path +from leads.views.kanban_views import ( + LeadKanbanView, + LeadMoveView, + LeadPipelineDetailView, + LeadPipelineListCreateView, + LeadStageCreateView, + LeadStageDetailView, + LeadStageReorderView, +) from leads.views.lead_interactions import ( CreateLeadFromSite, LeadAttachmentView, @@ -7,15 +16,6 @@ from leads.views.lead_interactions import ( LeadUploadView, ) from leads.views.lead_views import LeadDetailView, LeadListView -from leads.views.kanban_views import ( - LeadKanbanView, - LeadMoveView, - LeadPipelineListCreateView, - LeadPipelineDetailView, - LeadStageCreateView, - LeadStageDetailView, - LeadStageReorderView, -) app_name = "api_leads" diff --git a/backend/leads/views/kanban_views.py b/backend/leads/views/kanban_views.py index ec73c5ba..7ef80d18 100644 --- a/backend/leads/views/kanban_views.py +++ b/backend/leads/views/kanban_views.py @@ -8,21 +8,21 @@ from decimal import Decimal from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from common.permissions import HasOrgContext, is_org_admin -from common.validators import date_param, uuid_param from common.utils import LEAD_STATUS +from common.validators import date_param, uuid_param from leads.models import Lead, LeadPipeline, LeadStage from leads.serializer import ( LeadKanbanCardSerializer, LeadMoveSerializer, - LeadPipelineSerializer, LeadPipelineListSerializer, + LeadPipelineSerializer, LeadStageSerializer, ) diff --git a/backend/leads/views/lead_interactions.py b/backend/leads/views/lead_interactions.py index dd2fbd97..85e84da8 100644 --- a/backend/leads/views/lead_interactions.py +++ b/backend/leads/views/lead_interactions.py @@ -316,9 +316,7 @@ class CreateLeadFromSite(APIView): # FRONTEND_URL; it used to be handed a base derived from this # request's Host header, which named the API rather than the web # app and let a caller of this endpoint influence the link. - send_lead_assigned_emails.delay( - lead.id, [user.id], str(api_setting.org.id) - ) + send_lead_assigned_emails.delay(lead.id, [user.id], str(api_setting.org.id)) # Create Contact try: contact = Contact.objects.create( diff --git a/backend/leads/views/lead_views.py b/backend/leads/views/lead_views.py index 6484c2ec..a455fcc2 100644 --- a/backend/leads/views/lead_views.py +++ b/backend/leads/views/lead_views.py @@ -385,7 +385,9 @@ class LeadListView(APIView, LimitOffsetPagination): # Send email to assigned users (after assignees are set) # Skip if status is "converted" - that branch handles its own email if data.get("status") != "converted": - recipients = list(lead_obj.assigned_to.all().values_list("id", flat=True)) + recipients = list( + lead_obj.assigned_to.all().values_list("id", flat=True) + ) if recipients: send_email_to_assigned_user.delay( recipients, @@ -401,7 +403,9 @@ class LeadListView(APIView, LimitOffsetPagination): ) # Send email to assigned users for converted leads - recipients = list(lead_obj.assigned_to.all().values_list("id", flat=True)) + recipients = list( + lead_obj.assigned_to.all().values_list("id", flat=True) + ) if recipients: send_email_to_assigned_user.delay( recipients, @@ -802,7 +806,9 @@ class LeadDetailView(APIView): lead_obj.assigned_to.all().values_list("id", flat=True) ) # Only email users who were newly assigned - recipients = list(set(current_assigned_users) - set(previous_assigned_to_users)) + recipients = list( + set(current_assigned_users) - set(previous_assigned_to_users) + ) if recipients: send_email_to_assigned_user.delay( recipients, @@ -818,7 +824,9 @@ class LeadDetailView(APIView): ) # Send email to all assigned users for converted leads - recipients = list(lead_obj.assigned_to.all().values_list("id", flat=True)) + recipients = list( + lead_obj.assigned_to.all().values_list("id", flat=True) + ) if recipients: send_email_to_assigned_user.delay( recipients, @@ -968,7 +976,9 @@ class LeadDetailView(APIView): ) # Send email to assigned users for converted leads - recipients = list(self.lead_obj.assigned_to.all().values_list("id", flat=True)) + recipients = list( + self.lead_obj.assigned_to.all().values_list("id", flat=True) + ) if recipients: send_email_to_assigned_user.delay( recipients, @@ -1067,7 +1077,9 @@ class LeadDetailView(APIView): current_assigned_users = list( lead_obj.assigned_to.all().values_list("id", flat=True) ) - recipients = list(set(current_assigned_users) - set(previous_assigned_to_users)) + recipients = list( + set(current_assigned_users) - set(previous_assigned_to_users) + ) if recipients: send_email_to_assigned_user.delay( recipients, diff --git a/backend/macros/migrations/0001_initial.py b/backend/macros/migrations/0001_initial.py index 52d0c837..ad1b9d1c 100644 --- a/backend/macros/migrations/0001_initial.py +++ b/backend/macros/migrations/0001_initial.py @@ -24,7 +24,6 @@ def disable_rls(apps, schema_editor): class Migration(migrations.Migration): - initial = True # RLS policy creation can't run inside an atomic block. diff --git a/backend/macros/serializers.py b/backend/macros/serializers.py index 77f4ea1a..47c0106c 100644 --- a/backend/macros/serializers.py +++ b/backend/macros/serializers.py @@ -43,7 +43,7 @@ class MacroSerializer(serializers.ModelSerializer): def get_owner_name(self, obj): # `User` only stores `email`, no first_name/last_name on this model - #, so we surface the email directly. Frontend can display whatever + # , so we surface the email directly. Frontend can display whatever # form it wants from there. if obj.owner is None or obj.owner.user is None: return None diff --git a/backend/opportunity/migrations/0006_add_opportunity_line_item.py b/backend/opportunity/migrations/0006_add_opportunity_line_item.py index f48ea53b..cb71f955 100644 --- a/backend/opportunity/migrations/0006_add_opportunity_line_item.py +++ b/backend/opportunity/migrations/0006_add_opportunity_line_item.py @@ -1,9 +1,10 @@ # Generated migration for OpportunityLineItem model -from django.db import migrations, models -import django.db.models.deletion import uuid +import django.db.models.deletion +from django.db import migrations, models + class Migration(migrations.Migration): dependencies = [ diff --git a/backend/opportunity/migrations/0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more.py b/backend/opportunity/migrations/0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more.py index 3c50dca6..2daf1cfc 100644 --- a/backend/opportunity/migrations/0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more.py +++ b/backend/opportunity/migrations/0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more.py @@ -1,6 +1,7 @@ # Generated by Django 5.2.9 on 2025-12-09 09:24 import uuid + from django.db import migrations, models diff --git a/backend/opportunity/migrations/0008_add_stage_aging.py b/backend/opportunity/migrations/0008_add_stage_aging.py index 351e8181..b0555a2f 100644 --- a/backend/opportunity/migrations/0008_add_stage_aging.py +++ b/backend/opportunity/migrations/0008_add_stage_aging.py @@ -1,7 +1,8 @@ # Generated by Django 5.2.11 on 2026-02-18 12:42 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models @@ -14,45 +15,118 @@ def backfill_stage_changed_at(apps, schema_editor): """ from django.utils import timezone - Opportunity = apps.get_model('opportunity', 'Opportunity') + Opportunity = apps.get_model("opportunity", "Opportunity") Opportunity.objects.filter(stage_changed_at__isnull=True).update( stage_changed_at=timezone.now() ) class Migration(migrations.Migration): - dependencies = [ - ('common', '0012_add_tag_color_description_isactive'), - ('opportunity', '0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more'), + ("common", "0012_add_tag_color_description_isactive"), + ( + "opportunity", + "0007_rename_opportunity__opportu_5e4c7e_idx_opportunity_opportu_456e8f_idx_and_more", + ), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='opportunity', - name='stage_changed_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='Stage Changed At'), + model_name="opportunity", + name="stage_changed_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Stage Changed At" + ), ), migrations.RunPython(backfill_stage_changed_at, migrations.RunPython.noop), migrations.CreateModel( - name='StageAgingConfig', + name="StageAgingConfig", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('stage', models.CharField(choices=[('PROSPECTING', 'Prospecting'), ('QUALIFICATION', 'Qualification'), ('PROPOSAL', 'Proposal'), ('NEGOTIATION', 'Negotiation'), ('CLOSED_WON', 'Closed Won'), ('CLOSED_LOST', 'Closed Lost')], max_length=64, verbose_name='Stage')), - ('expected_days', models.PositiveIntegerField(default=14, verbose_name='Expected Days')), - ('warning_days', models.PositiveIntegerField(blank=True, null=True, verbose_name='Warning Days')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stage_aging_configs', to='common.org')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "stage", + models.CharField( + choices=[ + ("PROSPECTING", "Prospecting"), + ("QUALIFICATION", "Qualification"), + ("PROPOSAL", "Proposal"), + ("NEGOTIATION", "Negotiation"), + ("CLOSED_WON", "Closed Won"), + ("CLOSED_LOST", "Closed Lost"), + ], + max_length=64, + verbose_name="Stage", + ), + ), + ( + "expected_days", + models.PositiveIntegerField( + default=14, verbose_name="Expected Days" + ), + ), + ( + "warning_days", + models.PositiveIntegerField( + blank=True, null=True, verbose_name="Warning Days" + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stage_aging_configs", + to="common.org", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Stage Aging Config', - 'verbose_name_plural': 'Stage Aging Configs', - 'db_table': 'stage_aging_config', - 'unique_together': {('org', 'stage')}, + "verbose_name": "Stage Aging Config", + "verbose_name_plural": "Stage Aging Configs", + "db_table": "stage_aging_config", + "unique_together": {("org", "stage")}, }, ), ] diff --git a/backend/opportunity/migrations/0009_salesgoal.py b/backend/opportunity/migrations/0009_salesgoal.py index cef1909a..cdf5ea60 100644 --- a/backend/opportunity/migrations/0009_salesgoal.py +++ b/backend/opportunity/migrations/0009_salesgoal.py @@ -1,48 +1,147 @@ # Generated by Django 5.2.11 on 2026-02-18 16:07 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('common', '0012_add_tag_color_description_isactive'), - ('opportunity', '0008_add_stage_aging'), + ("common", "0012_add_tag_color_description_isactive"), + ("opportunity", "0008_add_stage_aging"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='SalesGoal', + name="SalesGoal", fields=[ - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), - ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), - ('name', models.CharField(max_length=255, verbose_name='Goal Name')), - ('goal_type', models.CharField(choices=[('REVENUE', 'Revenue'), ('DEALS_CLOSED', 'Deals Closed')], max_length=20, verbose_name='Goal Type')), - ('target_value', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Target Value')), - ('period_type', models.CharField(choices=[('MONTHLY', 'Monthly'), ('QUARTERLY', 'Quarterly'), ('YEARLY', 'Yearly'), ('CUSTOM', 'Custom')], max_length=20, verbose_name='Period Type')), - ('period_start', models.DateField(verbose_name='Period Start')), - ('period_end', models.DateField(verbose_name='Period End')), - ('is_active', models.BooleanField(default=True)), - ('milestone_50_notified', models.BooleanField(default=False)), - ('milestone_90_notified', models.BooleanField(default=False)), - ('milestone_100_notified', models.BooleanField(default=False)), - ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_goals', to='common.profile')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), - ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sales_goals', to='common.org')), - ('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sales_goals', to='common.teams')), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("name", models.CharField(max_length=255, verbose_name="Goal Name")), + ( + "goal_type", + models.CharField( + choices=[ + ("REVENUE", "Revenue"), + ("DEALS_CLOSED", "Deals Closed"), + ], + max_length=20, + verbose_name="Goal Type", + ), + ), + ( + "target_value", + models.DecimalField( + decimal_places=2, max_digits=12, verbose_name="Target Value" + ), + ), + ( + "period_type", + models.CharField( + choices=[ + ("MONTHLY", "Monthly"), + ("QUARTERLY", "Quarterly"), + ("YEARLY", "Yearly"), + ("CUSTOM", "Custom"), + ], + max_length=20, + verbose_name="Period Type", + ), + ), + ("period_start", models.DateField(verbose_name="Period Start")), + ("period_end", models.DateField(verbose_name="Period End")), + ("is_active", models.BooleanField(default=True)), + ("milestone_50_notified", models.BooleanField(default=False)), + ("milestone_90_notified", models.BooleanField(default=False)), + ("milestone_100_notified", models.BooleanField(default=False)), + ( + "assigned_to", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sales_goals", + to="common.profile", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "org", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sales_goals", + to="common.org", + ), + ), + ( + "team", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sales_goals", + to="common.teams", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), ], options={ - 'verbose_name': 'Sales Goal', - 'verbose_name_plural': 'Sales Goals', - 'db_table': 'sales_goal', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['org', '-created_at'], name='sales_goal_org_id_363225_idx'), models.Index(fields=['org', 'period_start', 'period_end'], name='sales_goal_org_id_f5e0bd_idx')], + "verbose_name": "Sales Goal", + "verbose_name_plural": "Sales Goals", + "db_table": "sales_goal", + "ordering": ["-created_at"], + "indexes": [ + models.Index( + fields=["org", "-created_at"], + name="sales_goal_org_id_363225_idx", + ), + models.Index( + fields=["org", "period_start", "period_end"], + name="sales_goal_org_id_f5e0bd_idx", + ), + ], }, ), ] diff --git a/backend/opportunity/migrations/0010_opportunity_custom_fields.py b/backend/opportunity/migrations/0010_opportunity_custom_fields.py index 49e62c16..8101c9c9 100644 --- a/backend/opportunity/migrations/0010_opportunity_custom_fields.py +++ b/backend/opportunity/migrations/0010_opportunity_custom_fields.py @@ -2,15 +2,18 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('opportunity', '0009_salesgoal'), + ("opportunity", "0009_salesgoal"), ] operations = [ migrations.AddField( - model_name='opportunity', - name='custom_fields', - field=models.JSONField(blank=True, default=dict, help_text='Per-org schema extension; values are validated against common.CustomFieldDefinition.'), + model_name="opportunity", + name="custom_fields", + field=models.JSONField( + blank=True, + default=dict, + help_text="Per-org schema extension; values are validated against common.CustomFieldDefinition.", + ), ), ] diff --git a/backend/opportunity/migrations/0014_opportunity_is_sample.py b/backend/opportunity/migrations/0014_opportunity_is_sample.py index a2fd321c..426ff0a3 100644 --- a/backend/opportunity/migrations/0014_opportunity_is_sample.py +++ b/backend/opportunity/migrations/0014_opportunity_is_sample.py @@ -4,15 +4,17 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('opportunity', '0013_enable_rls_stage_aging_config'), + ("opportunity", "0013_enable_rls_stage_aging_config"), ] operations = [ migrations.AddField( - model_name='opportunity', - name='is_sample', - field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + model_name="opportunity", + name="is_sample", + field=models.BooleanField( + default=False, + help_text="True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.", + ), ), ] diff --git a/backend/opportunity/models.py b/backend/opportunity/models.py index be0d027b..b5336271 100644 --- a/backend/opportunity/models.py +++ b/backend/opportunity/models.py @@ -21,7 +21,6 @@ from common.utils import ( ) from contacts.models import Contact - # Amount source choices for Opportunity AMOUNT_SOURCE_CHOICES = ( ("MANUAL", "Manual"), @@ -215,7 +214,11 @@ class Opportunity(AssignableMixin, BaseModel): aging_configs: Optional dict {stage: StageAgingConfig} to avoid DB queries. Pass this when processing lists to prevent N+1. """ - from .workflow import CLOSED_STAGES, DEFAULT_STAGE_EXPECTED_DAYS, ROTTEN_MULTIPLIER + from .workflow import ( + CLOSED_STAGES, + DEFAULT_STAGE_EXPECTED_DAYS, + ROTTEN_MULTIPLIER, + ) if self.stage in CLOSED_STAGES: return "green" @@ -353,7 +356,9 @@ class OpportunityLineItem(BaseModel): ] def __str__(self): - display_name = self.name if self.name else (self.product.name if self.product else "Item") + display_name = ( + self.name if self.name else (self.product.name if self.product else "Item") + ) return f"{display_name} x {self.quantity}" def save(self, *args, **kwargs): @@ -411,12 +416,8 @@ class StageAgingConfig(BaseModel): related_name="stage_aging_configs", ) stage = models.CharField(_("Stage"), max_length=64, choices=STAGES) - expected_days = models.PositiveIntegerField( - _("Expected Days"), default=14 - ) - warning_days = models.PositiveIntegerField( - _("Warning Days"), null=True, blank=True - ) + expected_days = models.PositiveIntegerField(_("Expected Days"), default=14) + warning_days = models.PositiveIntegerField(_("Warning Days"), null=True, blank=True) class Meta: verbose_name = "Stage Aging Config" @@ -432,9 +433,7 @@ class SalesGoal(BaseModel): """Sales goal / quota for tracking revenue or deals closed targets.""" name = models.CharField(_("Goal Name"), max_length=255) - goal_type = models.CharField( - _("Goal Type"), max_length=20, choices=GOAL_TYPES - ) + goal_type = models.CharField(_("Goal Type"), max_length=20, choices=GOAL_TYPES) target_value = models.DecimalField( _("Target Value"), max_digits=12, decimal_places=2 ) @@ -498,15 +497,13 @@ class SalesGoal(BaseModel): if self.assigned_to: opps = opps.filter(assigned_to=self.assigned_to) elif self.team: - team_members = Profile.objects.filter( - user_teams=self.team, is_active=True - ) + team_members = Profile.objects.filter(user_teams=self.team, is_active=True) opps = opps.filter(assigned_to__in=team_members) if self.goal_type == "REVENUE": - result = opps.aggregate( - total=Coalesce(Sum("amount"), Decimal("0")) - )["total"] + result = opps.aggregate(total=Coalesce(Sum("amount"), Decimal("0")))[ + "total" + ] else: result = Decimal(str(opps.count())) diff --git a/backend/opportunity/serializer.py b/backend/opportunity/serializer.py index 789b8ced..7e5fb212 100644 --- a/backend/opportunity/serializer.py +++ b/backend/opportunity/serializer.py @@ -12,10 +12,14 @@ from common.serializer import ( ) from contacts.serializer import ContactSerializer from invoices.serializer import ProductSerializer -from opportunity.models import Opportunity, OpportunityLineItem, SalesGoal, StageAgingConfig +from opportunity.models import ( + Opportunity, + OpportunityLineItem, + SalesGoal, + StageAgingConfig, +) from opportunity.workflow import AMOUNT_REQUIRED_STAGES, CLOSED_STAGES - # Note: Removed unused serializer properties that were computed but never used by frontend: # - get_team_users, get_team_and_assigned_users, get_assigned_users_not_in_teams @@ -345,7 +349,11 @@ class OpportunityKanbanCardSerializer(serializers.ModelSerializer): # stats bar and per-column totals; aliasing `amount` lets the board light # those up without a frontend transform layer. opportunity_amount = serializers.DecimalField( - source="amount", max_digits=12, decimal_places=2, allow_null=True, read_only=True + source="amount", + max_digits=12, + decimal_places=2, + allow_null=True, + read_only=True, ) class Meta: @@ -387,7 +395,9 @@ class OpportunityMoveSerializer(serializers.Serializer): compute the fractional kanban_order; explicit `kanban_order` wins if sent. """ - stage = serializers.ChoiceField(choices=Opportunity._meta.get_field("stage").choices) + stage = serializers.ChoiceField( + choices=Opportunity._meta.get_field("stage").choices + ) kanban_order = serializers.DecimalField( max_digits=15, decimal_places=6, required=False ) @@ -539,13 +549,17 @@ class SalesGoalCreateSerializer(serializers.ModelSerializer): return value def validate(self, data): - period_start = data.get("period_start", getattr(self.instance, "period_start", None)) + period_start = data.get( + "period_start", getattr(self.instance, "period_start", None) + ) period_end = data.get("period_end", getattr(self.instance, "period_end", None)) if period_start and period_end and period_end <= period_start: raise serializers.ValidationError( {"period_end": "Period end must be after period start."} ) - target_value = data.get("target_value", getattr(self.instance, "target_value", None)) + target_value = data.get( + "target_value", getattr(self.instance, "target_value", None) + ) if target_value is not None and target_value <= 0: raise serializers.ValidationError( {"target_value": "Target value must be greater than 0."} diff --git a/backend/opportunity/tasks.py b/backend/opportunity/tasks.py index 2f2ecb42..a883f1f8 100644 --- a/backend/opportunity/tasks.py +++ b/backend/opportunity/tasks.py @@ -53,7 +53,11 @@ def send_email_to_assigned_user(recipients, opportunity_id, org_id): @shared_task def check_stale_opportunities(): """Daily task: find rotten deals across all orgs and send email alerts.""" - from opportunity.workflow import CLOSED_STAGES, DEFAULT_STAGE_EXPECTED_DAYS, ROTTEN_MULTIPLIER + from opportunity.workflow import ( + CLOSED_STAGES, + DEFAULT_STAGE_EXPECTED_DAYS, + ROTTEN_MULTIPLIER, + ) now = timezone.now() orgs = Org.objects.filter(is_active=True) @@ -66,9 +70,11 @@ def check_stale_opportunities(): c.stage: c for c in StageAgingConfig.objects.filter(org=org) } - open_opps = Opportunity.objects.filter( - org=org, is_active=True - ).exclude(stage__in=CLOSED_STAGES).select_related("org") + open_opps = ( + Opportunity.objects.filter(org=org, is_active=True) + .exclude(stage__in=CLOSED_STAGES) + .select_related("org") + ) stale_opps = [] for opp in open_opps: @@ -218,9 +224,7 @@ def check_goal_milestones(): profile, goal, milestone_label, pct, achieved ) except Exception: - logger.exception( - "Error processing goal milestones for org %s", org.id - ) + logger.exception("Error processing goal milestones for org %s", org.id) finally: # Worker threads outlive a task; the next one must not inherit this # org's day. Same reason the request middleware deactivates. @@ -238,9 +242,7 @@ def _send_goal_milestone_email(profile, goal, milestone_label, percent, achieved "url": frontend_url("/goals"), } subject = f"[BottleCRM] Goal '{goal.name}' reached {milestone_label}!" - html_content = render_to_string( - "opportunity/goal_milestone.html", context=context - ) + html_content = render_to_string("opportunity/goal_milestone.html", context=context) msg = EmailMessage( subject, html_content, diff --git a/backend/opportunity/tests/test_attachment_delete_authz.py b/backend/opportunity/tests/test_attachment_delete_authz.py index 7484be11..7f3b32bc 100644 --- a/backend/opportunity/tests/test_attachment_delete_authz.py +++ b/backend/opportunity/tests/test_attachment_delete_authz.py @@ -21,10 +21,14 @@ from opportunity.models import Opportunity def _attachment(org, opportunity, creator): - # `BaseModel.save()` overwrites `created_by` from crum's current user, and - # in a test there is none, so passing `created_by=` alone silently stores - # NULL and the uploader assertions below would pass for the wrong reason. - # This is the same trap that made the lead CSV importer create nothing. + # `impersonate` puts a current user in crum's thread local, so this exercises + # the same stamping path a real upload takes rather than setting the column + # directly. It used to be mandatory: `BaseModel.save()` answered "no current + # user" by storing NULL over an explicit `created_by=`, so the uploader + # assertions below would have passed for the wrong reason. That is fixed + # (see `common/tests/test_base_model_audit_stamps.py`) and a plain + # `created_by=` would work now, but going through the request path is still + # the more faithful setup, so it stays. with impersonate(creator): return Attachments.objects.create( file_name="proposal.txt", @@ -49,7 +53,9 @@ def opp_b(org_b): class TestOpportunityAttachmentDelete: URL = "/api/opportunities/attachment/{}/" - def test_admin_deletes_own_org_attachment(self, org_a, admin_client, admin_user, opp_a): + def test_admin_deletes_own_org_attachment( + self, org_a, admin_client, admin_user, opp_a + ): att = _attachment(org_a, opp_a, admin_user) assert admin_client.delete(self.URL.format(att.id)).status_code == 200 assert not Attachments.objects.filter(id=att.id).exists() diff --git a/backend/opportunity/tests/test_custom_fields.py b/backend/opportunity/tests/test_custom_fields.py index 6a7edbda..ba8318e9 100644 --- a/backend/opportunity/tests/test_custom_fields.py +++ b/backend/opportunity/tests/test_custom_fields.py @@ -25,9 +25,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) def _make_tier_def(org, **overrides): diff --git a/backend/opportunity/tests/test_deal_access_and_totals.py b/backend/opportunity/tests/test_deal_access_and_totals.py index 020427e3..242644ec 100644 --- a/backend/opportunity/tests/test_deal_access_and_totals.py +++ b/backend/opportunity/tests/test_deal_access_and_totals.py @@ -333,9 +333,7 @@ class TestDealListTotals: """A rep's header counts the rep's deals. The same subset the list shows them, since the queryset is already narrowed before totalling.""" _created_by(_deal(org_a, name="Theirs", amount=Decimal("500")), admin_user) - _created_by( - _deal(org_a, name="Mine", amount=Decimal("300")), user_profile.user - ) + _created_by(_deal(org_a, name="Mine", amount=Decimal("300")), user_profile.user) totals = user_client.get(OPPORTUNITIES_LIST_URL).data["totals"] assert totals["count"] == 1 assert Decimal(totals["amount_sum"]) == Decimal("300") diff --git a/backend/opportunity/tests/test_deal_aging.py b/backend/opportunity/tests/test_deal_aging.py index e94af69d..64d78db0 100644 --- a/backend/opportunity/tests/test_deal_aging.py +++ b/backend/opportunity/tests/test_deal_aging.py @@ -104,9 +104,7 @@ class TestPerOrgConfig: def test_custom_expected_days(self, org_a, opportunity): # Set custom config: 5 days expected for PROSPECTING - StageAgingConfig.objects.create( - org=org_a, stage="PROSPECTING", expected_days=5 - ) + StageAgingConfig.objects.create(org=org_a, stage="PROSPECTING", expected_days=5) opportunity.stage_changed_at = timezone.now() - timedelta(days=6) opportunity.save(update_fields=["stage_changed_at"]) @@ -126,9 +124,7 @@ class TestPerOrgConfig: def test_custom_config_red_threshold(self, org_a, opportunity): # Set custom config: 5 days expected → rotten at 5 * 1.5 = 7.5 - StageAgingConfig.objects.create( - org=org_a, stage="PROSPECTING", expected_days=5 - ) + StageAgingConfig.objects.create(org=org_a, stage="PROSPECTING", expected_days=5) opportunity.stage_changed_at = timezone.now() - timedelta(days=8) opportunity.save(update_fields=["stage_changed_at"]) @@ -253,7 +249,9 @@ class TestStaleDealsTask: """Test the Celery task for detecting stale deals.""" @patch("opportunity.tasks.send_stale_deals_alert") - def test_task_detects_stale_deals(self, mock_alert, org_a, admin_user, admin_profile): + def test_task_detects_stale_deals( + self, mock_alert, org_a, admin_user, admin_profile + ): # Create a rotten deal assigned to admin rotten = Opportunity.objects.create( name="Very Stale", diff --git a/backend/opportunity/tests/test_opportunities_api.py b/backend/opportunity/tests/test_opportunities_api.py index 1d2fe3c5..2b6a8d15 100644 --- a/backend/opportunity/tests/test_opportunities_api.py +++ b/backend/opportunity/tests/test_opportunities_api.py @@ -28,7 +28,6 @@ from common.models import Attachments, Comment, Tags, Teams from contacts.models import Contact from opportunity.models import Opportunity, OpportunityLineItem - OPPORTUNITIES_LIST_URL = "/api/opportunities/" @@ -59,9 +58,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) @pytest.fixture @@ -107,9 +104,7 @@ class TestOpportunityListView: "name": "New Deal", "stage": "QUALIFICATION", } - response = admin_client.post( - OPPORTUNITIES_LIST_URL, payload, format="json" - ) + response = admin_client.post(OPPORTUNITIES_LIST_URL, payload, format="json") assert response.status_code == status.HTTP_200_OK assert response.data["error"] is False @@ -129,9 +124,7 @@ class TestOpportunityListView: "stage": "QUALIFICATION", "account": str(account.pk), } - response = admin_client.post( - OPPORTUNITIES_LIST_URL, payload, format="json" - ) + response = admin_client.post(OPPORTUNITIES_LIST_URL, payload, format="json") assert response.status_code == status.HTTP_200_OK assert response.data["error"] is False @@ -152,9 +145,7 @@ class TestOpportunityListView: assert not Opportunity.objects.filter(name="Unauthorized Deal").exists() @patch("opportunity.views.opportunity_views.send_email_to_assigned_user.delay") - def test_org_isolation( - self, mock_email, admin_client, org_b_client, opp_a, opp_b - ): + def test_org_isolation(self, mock_email, admin_client, org_b_client, opp_a, opp_b): """Opportunities in org_a must not appear in org_b's list.""" response = org_b_client.get(OPPORTUNITIES_LIST_URL) assert response.status_code == status.HTTP_200_OK @@ -178,9 +169,7 @@ class TestOpportunityListView: "description": "A large enterprise deal", "closed_on": "2025-12-31", } - response = admin_client.post( - OPPORTUNITIES_LIST_URL, payload, format="json" - ) + response = admin_client.post(OPPORTUNITIES_LIST_URL, payload, format="json") assert response.status_code == status.HTTP_200_OK assert response.data["error"] is False opp = Opportunity.objects.get(name="Full Opportunity") @@ -189,9 +178,7 @@ class TestOpportunityListView: assert opp.description == "A large enterprise deal" @patch("opportunity.views.opportunity_views.send_email_to_assigned_user.delay") - def test_create_opportunity_with_tags( - self, mock_email, admin_client, org_a - ): + def test_create_opportunity_with_tags(self, mock_email, admin_client, org_a): """Creating an opportunity with tags associates them.""" _set_rls(org_a) tag = Tags.objects.create(name="Enterprise", org=org_a) @@ -200,9 +187,7 @@ class TestOpportunityListView: "stage": "QUALIFICATION", "tags": [str(tag.id)], } - response = admin_client.post( - OPPORTUNITIES_LIST_URL, payload, format="json" - ) + response = admin_client.post(OPPORTUNITIES_LIST_URL, payload, format="json") assert response.status_code == status.HTTP_200_OK opp = Opportunity.objects.get(name="Tagged Opportunity") assert tag in opp.tags.all() @@ -225,9 +210,7 @@ class TestOpportunityListView: "stage": "QUALIFICATION", "contacts": [str(contact.id)], } - response = admin_client.post( - OPPORTUNITIES_LIST_URL, payload, format="json" - ) + response = admin_client.post(OPPORTUNITIES_LIST_URL, payload, format="json") assert response.status_code == status.HTTP_200_OK opp = Opportunity.objects.get(name="Contact Opportunity") assert contact in opp.contacts.all() @@ -242,9 +225,7 @@ class TestOpportunityListView: "stage": "QUALIFICATION", "assigned_to": [str(admin_profile.id)], } - response = admin_client.post( - OPPORTUNITIES_LIST_URL, payload, format="json" - ) + response = admin_client.post(OPPORTUNITIES_LIST_URL, payload, format="json") assert response.status_code == status.HTTP_200_OK opp = Opportunity.objects.get(name="Assigned Opportunity") assert admin_profile in opp.assigned_to.all() @@ -256,17 +237,13 @@ class TestOpportunityListView: ): """Creating an opportunity with teams associates them.""" _set_rls(org_a) - team = Teams.objects.create( - name="Sales Team", created_by=admin_user, org=org_a - ) + team = Teams.objects.create(name="Sales Team", created_by=admin_user, org=org_a) payload = { "name": "Teamed Opportunity", "stage": "QUALIFICATION", "teams": [str(team.id)], } - response = admin_client.post( - OPPORTUNITIES_LIST_URL, payload, format="json" - ) + response = admin_client.post(OPPORTUNITIES_LIST_URL, payload, format="json") assert response.status_code == status.HTTP_200_OK opp = Opportunity.objects.get(name="Teamed Opportunity") assert team in opp.teams.all() @@ -282,9 +259,7 @@ class TestOpportunityListView: "amount": "10000.00", "closed_on": "2025-06-15", } - response = admin_client.post( - OPPORTUNITIES_LIST_URL, payload, format="json" - ) + response = admin_client.post(OPPORTUNITIES_LIST_URL, payload, format="json") assert response.status_code == status.HTTP_200_OK def test_create_opportunity_invalid_data(self, admin_client): @@ -295,40 +270,30 @@ class TestOpportunityListView: assert response.status_code == status.HTTP_400_BAD_REQUEST @patch("opportunity.views.opportunity_views.send_email_to_assigned_user.delay") - def test_create_opportunity_duplicate_name( - self, mock_email, admin_client, opp_a - ): + def test_create_opportunity_duplicate_name(self, mock_email, admin_client, opp_a): """Creating an opportunity with a duplicate name returns 400.""" payload = { "name": "Org A Deal", "stage": "QUALIFICATION", } - response = admin_client.post( - OPPORTUNITIES_LIST_URL, payload, format="json" - ) + response = admin_client.post(OPPORTUNITIES_LIST_URL, payload, format="json") assert response.status_code == status.HTTP_400_BAD_REQUEST def test_list_opportunities_search_filter(self, admin_client, opp_a): """Search filter works on name.""" - response = admin_client.get( - OPPORTUNITIES_LIST_URL, {"search": "Org A"} - ) + response = admin_client.get(OPPORTUNITIES_LIST_URL, {"search": "Org A"}) assert response.status_code == status.HTTP_200_OK names = [o["name"] for o in response.data["opportunities"]] assert "Org A Deal" in names def test_list_opportunities_stage_filter(self, admin_client, opp_a): """Stage filter works.""" - response = admin_client.get( - OPPORTUNITIES_LIST_URL, {"stage": "QUALIFICATION"} - ) + response = admin_client.get(OPPORTUNITIES_LIST_URL, {"stage": "QUALIFICATION"}) assert response.status_code == status.HTTP_200_OK def test_list_opportunities_name_filter(self, admin_client, opp_a): """Name filter works.""" - response = admin_client.get( - OPPORTUNITIES_LIST_URL, {"name": "Org A"} - ) + response = admin_client.get(OPPORTUNITIES_LIST_URL, {"name": "Org A"}) assert response.status_code == status.HTTP_200_OK def test_list_opportunities_created_at_filter(self, admin_client, opp_a): @@ -339,9 +304,7 @@ class TestOpportunityListView: ) assert response.status_code == status.HTTP_200_OK - def test_list_opportunities_amount_filter( - self, admin_client, admin_user, org_a - ): + def test_list_opportunities_amount_filter(self, admin_client, admin_user, org_a): """Amount range filter works.""" _set_rls(org_a) Opportunity.objects.create( @@ -351,9 +314,7 @@ class TestOpportunityListView: org=org_a, created_by=admin_user, ) - response = admin_client.get( - OPPORTUNITIES_LIST_URL, {"amount__gte": "50000"} - ) + response = admin_client.get(OPPORTUNITIES_LIST_URL, {"amount__gte": "50000"}) assert response.status_code == status.HTTP_200_OK def test_list_opportunities_context_keys(self, admin_client, org_a): @@ -424,9 +385,7 @@ class TestOpportunityDetailView: "name": "Updated Opp", "stage": "PROPOSAL", } - response = admin_client.put( - _detail_url(opportunity.pk), payload, format="json" - ) + response = admin_client.put(_detail_url(opportunity.pk), payload, format="json") assert response.status_code == status.HTTP_200_OK assert response.data["error"] is False opportunity.refresh_from_db() @@ -485,9 +444,7 @@ class TestOpportunityDetailView: def test_get_detail_not_found(self, admin_client): """Getting a non-existent opportunity returns 404.""" - response = admin_client.get( - _detail_url("00000000-0000-0000-0000-000000000001") - ) + response = admin_client.get(_detail_url("00000000-0000-0000-0000-000000000001")) assert response.status_code == status.HTTP_404_NOT_FOUND @patch("opportunity.views.opportunity_views.send_email_to_assigned_user.delay") @@ -561,9 +518,7 @@ class TestOpportunityDetailView: ): """PUT with teams updates team associations.""" _set_rls(org_a) - team = Teams.objects.create( - name="Opp Team", created_by=admin_user, org=org_a - ) + team = Teams.objects.create(name="Opp Team", created_by=admin_user, org=org_a) response = admin_client.put( _detail_url(opportunity.pk), { @@ -649,9 +604,7 @@ class TestOpportunityDetailView: assert opportunity.stage == "PROPOSAL" assert opportunity.name == "Detail Opp" # unchanged - def test_patch_opportunity_with_tags( - self, admin_client, opportunity, org_a - ): + def test_patch_opportunity_with_tags(self, admin_client, opportunity, org_a): """PATCH with tags replaces existing tags.""" _set_rls(org_a) tag1 = Tags.objects.create(name="PatchOldO", org=org_a) @@ -803,7 +756,9 @@ class TestOpportunityCommentView: ) return opp, comment - def test_update_comment_put(self, admin_client, comment_fixture, admin_profile, org_a): + def test_update_comment_put( + self, admin_client, comment_fixture, admin_profile, org_a + ): """Admin can update a comment via PUT.""" opp, comment = comment_fixture response = admin_client.put( @@ -998,9 +953,7 @@ class TestOpportunityLineItems: def test_get_line_item_detail(self, admin_client, opportunity, line_item): """Admin can retrieve a single line item.""" - response = admin_client.get( - _line_item_detail_url(opportunity.pk, line_item.pk) - ) + response = admin_client.get(_line_item_detail_url(opportunity.pk, line_item.pk)) assert response.status_code == status.HTTP_200_OK assert response.data["name"] == "Widget A" @@ -1146,9 +1099,7 @@ class TestOpportunityListFilters: org=org_a, created_by=admin_user, ) - response = admin_client.get( - OPPORTUNITIES_LIST_URL, {"lead_source": "CALL"} - ) + response = admin_client.get(OPPORTUNITIES_LIST_URL, {"lead_source": "CALL"}) assert response.status_code == status.HTTP_200_OK names = [o["name"] for o in response.data["opportunities"]] assert "Call Opp" in names @@ -1213,9 +1164,7 @@ class TestOpportunityListFilters: org=org_a, created_by=admin_user, ) - response = admin_client.get( - OPPORTUNITIES_LIST_URL, {"amount__lte": "1000"} - ) + response = admin_client.get(OPPORTUNITIES_LIST_URL, {"amount__lte": "1000"}) assert response.status_code == status.HTTP_200_OK names = [o["name"] for o in response.data["opportunities"]] assert "Small Deal" in names @@ -1251,9 +1200,7 @@ class TestOpportunityListFilters: created_by=admin_user, ) - response = admin_client.get( - OPPORTUNITIES_LIST_URL, {"tags": str(tag_vip.id)} - ) + response = admin_client.get(OPPORTUNITIES_LIST_URL, {"tags": str(tag_vip.id)}) assert response.status_code == status.HTTP_200_OK names = {o["name"] for o in response.data["opportunities"]} assert names == {"Tagged Deal"} @@ -1263,9 +1210,7 @@ class TestOpportunityListFilters: class TestOpportunityDetailUsersMention: """Cover the users_mention branches in the GET detail view.""" - def test_users_mention_no_created_by( - self, user_client, org_a, user_profile - ): + def test_users_mention_no_created_by(self, user_client, org_a, user_profile): """Non-admin assigned to opp with no created_by gets empty users_mention.""" _set_rls(org_a) opp = Opportunity.objects.create( @@ -1746,13 +1691,22 @@ class TestOpportunityCommentViewOwner: class TestOpportunityAttachmentOwner: """Cover attachment permission checks for non-admin users.""" - def test_non_admin_creator_cannot_delete_attachment_due_to_type_mismatch( + def test_non_admin_creator_can_delete_their_own_attachment( self, user_client, regular_user, org_a, admin_user, user_profile ): - """Non-admin creator gets 403 because view compares Profile to User (type mismatch). + """The uploader may remove what they uploaded. - The view checks `request.profile == self.object.created_by` but created_by - is a User FK, not a Profile. So this condition is always False for non-admins. + This test used to assert the opposite, and named the reason in its own + title: the view compared `request.profile` to a `User` FK, so the + uploader branch could never be true. That was repaired on 2026-08-05 + (`request.profile.user == self.object.created_by`), and the test kept + passing anyway, because `BaseModel.save()` discarded the explicit + `created_by=regular_user` below and stored NULL. Two defects cancelling + left the assertion looking correct. With the stamp preserved, the + uploader is now recognised and the answer is 200. + + The dedicated file `test_attachment_delete_authz.py` covers the other + directions: another member gets 403, a cross-org admin gets 404. """ _set_rls(org_a) opp = Opportunity.objects.create( @@ -1772,7 +1726,8 @@ class TestOpportunityAttachmentOwner: org=org_a, ) response = user_client.delete(_attachment_url(attachment.id)) - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_200_OK + assert not Attachments.objects.filter(id=attachment.id).exists() @pytest.mark.django_db diff --git a/backend/opportunity/tests/test_sales_goals.py b/backend/opportunity/tests/test_sales_goals.py index b6f8c128..6bf77d91 100644 --- a/backend/opportunity/tests/test_sales_goals.py +++ b/backend/opportunity/tests/test_sales_goals.py @@ -5,11 +5,10 @@ from decimal import Decimal from unittest.mock import patch import pytest +from django.utils import timezone from common.models import Teams from opportunity.models import Opportunity, SalesGoal -from django.utils import timezone - # ---- Fixtures ---- # @@ -346,9 +345,7 @@ class TestSalesGoalAPI: data.update(overrides) return data - def test_create_goal_rejects_foreign_org_assigned_to( - self, admin_client, profile_b - ): + def test_create_goal_rejects_foreign_org_assigned_to(self, admin_client, profile_b): """An org_a admin cannot assign a goal to an org_b Profile. ``common_profile`` is not RLS-protected, so without the serializer's @@ -513,9 +510,7 @@ class TestLeaderboardAPI: class TestDashboardGoalSummary: - def test_dashboard_includes_goal_summary( - self, admin_client, goal_revenue - ): + def test_dashboard_includes_goal_summary(self, admin_client, goal_revenue): response = admin_client.get("/api/dashboard/") assert response.status_code == 200 assert "goal_summary" in response.data diff --git a/backend/opportunity/views/aging_views.py b/backend/opportunity/views/aging_views.py index 09c49aa7..bedf7a1b 100644 --- a/backend/opportunity/views/aging_views.py +++ b/backend/opportunity/views/aging_views.py @@ -16,10 +16,7 @@ class StageAgingConfigView(APIView): def get(self, request): """Return aging config for all open stages, with defaults for unconfigured stages.""" org = request.profile.org - configs = { - c.stage: c - for c in StageAgingConfig.objects.filter(org=org) - } + configs = {c.stage: c for c in StageAgingConfig.objects.filter(org=org)} result = [] for stage_value, stage_label in STAGES: @@ -29,12 +26,16 @@ class StageAgingConfigView(APIView): serializer = StageAgingConfigSerializer(configs[stage_value]) result.append(serializer.data) else: - result.append({ - "id": None, - "stage": stage_value, - "expected_days": DEFAULT_STAGE_EXPECTED_DAYS.get(stage_value, 14), - "warning_days": None, - }) + result.append( + { + "id": None, + "stage": stage_value, + "expected_days": DEFAULT_STAGE_EXPECTED_DAYS.get( + stage_value, 14 + ), + "warning_days": None, + } + ) return Response(result) def put(self, request): diff --git a/backend/opportunity/views/goal_views.py b/backend/opportunity/views/goal_views.py index b45e93de..e364a32f 100644 --- a/backend/opportunity/views/goal_views.py +++ b/backend/opportunity/views/goal_views.py @@ -31,9 +31,7 @@ class SalesGoalListView(APIView, LimitOffsetPagination): queryset = queryset.filter(is_active=True) if params.get("current") == "true": today = timezone.localdate() - queryset = queryset.filter( - period_start__lte=today, period_end__gte=today - ) + queryset = queryset.filter(period_start__lte=today, period_end__gte=today) assigned_to = uuid_param(params, "assigned_to") if assigned_to: queryset = queryset.filter(assigned_to_id=assigned_to) @@ -96,9 +94,7 @@ class SalesGoalDetailView(APIView): permission_classes = (IsAuthenticated, HasOrgContext) def get_object(self, pk, request): - return SalesGoal.objects.filter( - id=pk, org=request.profile.org - ).first() + return SalesGoal.objects.filter(id=pk, org=request.profile.org).first() def get(self, request, pk, *args, **kwargs): goal = self.get_object(pk, request) diff --git a/backend/opportunity/views/kanban_views.py b/backend/opportunity/views/kanban_views.py index c423ee6c..afcddf5f 100644 --- a/backend/opportunity/views/kanban_views.py +++ b/backend/opportunity/views/kanban_views.py @@ -24,16 +24,40 @@ from opportunity.serializer import ( OpportunityMoveSerializer, ) - # Column display config. Keys must match the stage choices in # common.utils.STAGES. Extra/unknown stages get the fallback. STAGE_CONFIG = { - "PROSPECTING": {"order": 1, "color": "#3B82F6", "type": "open", "label": "Prospecting"}, - "QUALIFICATION": {"order": 2, "color": "#8B5CF6", "type": "open", "label": "Qualification"}, - "PROPOSAL": {"order": 3, "color": "#F59E0B", "type": "in_progress", "label": "Proposal"}, - "NEGOTIATION": {"order": 4, "color": "#EF4444", "type": "in_progress", "label": "Negotiation"}, - "CLOSED_WON": {"order": 5, "color": "#22C55E", "type": "completed", "label": "Won"}, - "CLOSED_LOST": {"order": 6, "color": "#6B7280", "type": "completed", "label": "Lost"}, + "PROSPECTING": { + "order": 1, + "color": "#3B82F6", + "type": "open", + "label": "Prospecting", + }, + "QUALIFICATION": { + "order": 2, + "color": "#8B5CF6", + "type": "open", + "label": "Qualification", + }, + "PROPOSAL": { + "order": 3, + "color": "#F59E0B", + "type": "in_progress", + "label": "Proposal", + }, + "NEGOTIATION": { + "order": 4, + "color": "#EF4444", + "type": "in_progress", + "label": "Negotiation", + }, + "CLOSED_WON": {"order": 5, "color": "#22C55E", "type": "completed", "label": "Won"}, + "CLOSED_LOST": { + "order": 6, + "color": "#6B7280", + "type": "completed", + "label": "Lost", + }, } @@ -58,8 +82,7 @@ class OpportunityKanbanView(APIView): org = request.profile.org queryset = ( - Opportunity.objects - .filter(org=org) + Opportunity.objects.filter(org=org) .select_related("account") .prefetch_related("assigned_to", "tags") ) @@ -68,18 +91,14 @@ class OpportunityKanbanView(APIView): # or are assigned to. Kanban shouldn't reveal more than the table. if not is_org_admin(request.profile) and not request.user.is_superuser: queryset = queryset.filter( - Q(created_by=request.profile.user) - | Q(assigned_to=request.profile) + Q(created_by=request.profile.user) | Q(assigned_to=request.profile) ).distinct() queryset = self._apply_filters(queryset, request.query_params) # Aging configs prefetched once and passed via serializer context so # each card doesn't re-query StageAgingConfig. - aging_configs = { - c.stage: c - for c in StageAgingConfig.objects.filter(org=org) - } + aging_configs = {c.stage: c for c in StageAgingConfig.objects.filter(org=org)} columns = [] stage_choices = Opportunity._meta.get_field("stage").choices @@ -183,7 +202,10 @@ class OpportunityMoveView(APIView): # If the user is moving INTO a closed stage, stamp closed_by, mirrors # the OpportunityListView.post / OpportunityDetailView.put behavior so # kanban-driven close events look identical to form-driven ones. - if new_stage in ("CLOSED_WON", "CLOSED_LOST") and opportunity.stage != new_stage: + if ( + new_stage in ("CLOSED_WON", "CLOSED_LOST") + and opportunity.stage != new_stage + ): opportunity.closed_by = request.profile opportunity.stage = new_stage @@ -193,10 +215,7 @@ class OpportunityMoveView(APIView): # need to pass update_fields here, the model handles its own bookkeeping. opportunity.save() - aging_configs = { - c.stage: c - for c in StageAgingConfig.objects.filter(org=org) - } + aging_configs = {c.stage: c for c in StageAgingConfig.objects.filter(org=org)} return Response( { "error": False, diff --git a/backend/opportunity/views/opportunity_views.py b/backend/opportunity/views/opportunity_views.py index 0ac7048c..5880ad73 100644 --- a/backend/opportunity/views/opportunity_views.py +++ b/backend/opportunity/views/opportunity_views.py @@ -17,7 +17,14 @@ from rest_framework.views import APIView from accounts.models import Account from accounts.serializer import AccountSerializer, TagsSerializer from common.custom_fields import validate_payload as validate_custom_fields_payload -from common.models import Attachments, Comment, CustomFieldDefinition, Profile, Tags, Teams +from common.models import ( + Attachments, + Comment, + CustomFieldDefinition, + Profile, + Tags, + Teams, +) from common.permissions import HasOrgContext, is_org_admin from common.serializer import ( AttachmentsSerializer, @@ -44,7 +51,11 @@ from opportunity.serializer import ( OpportunitySerializer, ) from opportunity.tasks import send_email_to_assigned_user -from opportunity.workflow import CLOSED_STAGES, DEFAULT_STAGE_EXPECTED_DAYS, ROTTEN_MULTIPLIER +from opportunity.workflow import ( + CLOSED_STAGES, + DEFAULT_STAGE_EXPECTED_DAYS, + ROTTEN_MULTIPLIER, +) def stalled_filter(org): @@ -157,9 +168,7 @@ class OpportunityListView(APIView, LimitOffsetPagination): queryset = queryset.filter(tags__id__in=tags).distinct() assigned_to = uuid_list_param(params, "assigned_to") if assigned_to: - queryset = queryset.filter( - assigned_to__id__in=assigned_to - ).distinct() + queryset = queryset.filter(assigned_to__id__in=assigned_to).distinct() if params.get("search"): queryset = queryset.filter(name__icontains=params.get("search")) created_at_gte = date_param(params, "created_at__gte") @@ -208,16 +217,12 @@ class OpportunityListView(APIView, LimitOffsetPagination): context["totals"] = self.get_totals(queryset) # Prefetch aging configs for serializer context (avoids N+1) org = self.request.profile.org - aging_configs = { - c.stage: c - for c in StageAgingConfig.objects.filter(org=org) - } + aging_configs = {c.stage: c for c in StageAgingConfig.objects.filter(org=org)} results_opportunities = self.paginate_queryset( queryset.distinct(), self.request, view=self ) opportunities = OpportunitySerializer( - results_opportunities, many=True, - context={"aging_configs": aging_configs} + results_opportunities, many=True, context={"aging_configs": aging_configs} ).data if results_opportunities: offset = queryset.filter(id__gte=results_opportunities[-1].id).count() diff --git a/backend/orders/admin.py b/backend/orders/admin.py index 15f80d48..00fb7165 100644 --- a/backend/orders/admin.py +++ b/backend/orders/admin.py @@ -118,15 +118,11 @@ class OrderAdmin(admin.ModelAdmin): ), ( "Notes", - { - "fields": ("description",) - }, + {"fields": ("description",)}, ), ( "Organization", - { - "fields": ("org",) - }, + {"fields": ("org",)}, ), ) diff --git a/backend/orders/migrations/0001_initial.py b/backend/orders/migrations/0001_initial.py index 38bc3641..13164203 100644 --- a/backend/orders/migrations/0001_initial.py +++ b/backend/orders/migrations/0001_initial.py @@ -1,13 +1,13 @@ # Generated by Django 5.2.9 on 2026-02-19 02:29 -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/backend/orders/migrations/0002_replace_null_with_default_empty_on_char_fields.py b/backend/orders/migrations/0002_replace_null_with_default_empty_on_char_fields.py index b97977ae..1ca36107 100644 --- a/backend/orders/migrations/0002_replace_null_with_default_empty_on_char_fields.py +++ b/backend/orders/migrations/0002_replace_null_with_default_empty_on_char_fields.py @@ -23,7 +23,6 @@ UPDATE order_line_item SET description = COALESCE(description, ''); class Migration(migrations.Migration): - dependencies = [ ("orders", "0001_initial"), ] diff --git a/backend/orders/migrations/0003_remove_order_orders_org_id_9026a7_idx_and_more.py b/backend/orders/migrations/0003_remove_order_orders_org_id_9026a7_idx_and_more.py index 654fed59..e3c6a14c 100644 --- a/backend/orders/migrations/0003_remove_order_orders_org_id_9026a7_idx_and_more.py +++ b/backend/orders/migrations/0003_remove_order_orders_org_id_9026a7_idx_and_more.py @@ -4,7 +4,6 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ ("orders", "0002_replace_null_with_default_empty_on_char_fields"), ] diff --git a/backend/orders/migrations/0004_order_orders_org_id_9026a7_idx_and_more.py b/backend/orders/migrations/0004_order_orders_org_id_9026a7_idx_and_more.py index 4fafeb8e..dcedac1a 100644 --- a/backend/orders/migrations/0004_order_orders_org_id_9026a7_idx_and_more.py +++ b/backend/orders/migrations/0004_order_orders_org_id_9026a7_idx_and_more.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("accounts", "0008_account_is_sample"), ("common", "0034_rls_pipeline_tables"), diff --git a/backend/orders/models.py b/backend/orders/models.py index badafb6b..5bc9cb3e 100644 --- a/backend/orders/models.py +++ b/backend/orders/models.py @@ -4,7 +4,6 @@ from django.utils.translation import gettext_lazy as _ from common.base import BaseOrgModel from common.utils import COUNTRIES - # ============================================================================= # CONSTANTS # ============================================================================= diff --git a/backend/orders/tests/test_models.py b/backend/orders/tests/test_models.py index a44c19d2..ce04bc6f 100644 --- a/backend/orders/tests/test_models.py +++ b/backend/orders/tests/test_models.py @@ -25,7 +25,6 @@ from contacts.models import Contact from opportunity.models import Opportunity from orders.models import ORDER_STATUS, Order, OrderLineItem - # --------------------------------------------------------------------------- # Order model tests # --------------------------------------------------------------------------- @@ -79,9 +78,7 @@ class TestOrderModel: def test_create_order_all_fields(self, org_a): """Order can be created with all fields populated.""" account = Account.objects.create(name="Full Account", org=org_a) - contact = Contact.objects.create( - first_name="Jane", last_name="Doe", org=org_a - ) + contact = Contact.objects.create(first_name="Jane", last_name="Doe", org=org_a) opportunity = Opportunity.objects.create( name="Big Deal", account=account, @@ -135,9 +132,7 @@ class TestOrderModel: def test_order_contact_set_null_on_delete(self, org_a): """Deleting a contact should set order.contact to NULL.""" account = Account.objects.create(name="Test Account", org=org_a) - contact = Contact.objects.create( - first_name="John", last_name="Doe", org=org_a - ) + contact = Contact.objects.create(first_name="John", last_name="Doe", org=org_a) order = Order.objects.create( name="Contact Order", account=account, @@ -151,9 +146,7 @@ class TestOrderModel: def test_order_opportunity_set_null_on_delete(self, org_a): """Deleting an opportunity should set order.opportunity to NULL.""" account = Account.objects.create(name="Test Account", org=org_a) - opportunity = Opportunity.objects.create( - name="Opp", account=account, org=org_a - ) + opportunity = Opportunity.objects.create(name="Opp", account=account, org=org_a) order = Order.objects.create( name="Opp Order", account=account, @@ -190,9 +183,7 @@ class TestOrderModel: def test_order_has_timestamps(self, org_a): """Order should have created_at and updated_at timestamps.""" account = Account.objects.create(name="Timestamp Account", org=org_a) - order = Order.objects.create( - name="Timestamp Order", account=account, org=org_a - ) + order = Order.objects.create(name="Timestamp Order", account=account, org=org_a) assert order.created_at is not None assert order.updated_at is not None @@ -302,9 +293,7 @@ class TestOrderLineItemModel: def test_line_item_decimal_defaults(self, org_a): """Financial fields should default to expected values.""" order = self._make_order(org_a) - item = OrderLineItem.objects.create( - order=order, name="Default Item", org=org_a - ) + item = OrderLineItem.objects.create(order=order, name="Default Item", org=org_a) assert item.quantity == Decimal("1") assert item.unit_price == Decimal("0") assert item.discount_amount == Decimal("0") diff --git a/backend/scripts/generate_endpoint_index.py b/backend/scripts/generate_endpoint_index.py index ca395749..90aeb5d0 100644 --- a/backend/scripts/generate_endpoint_index.py +++ b/backend/scripts/generate_endpoint_index.py @@ -21,7 +21,9 @@ django.setup() from drf_spectacular.generators import SchemaGenerator # noqa: E402 -OUTPUT = Path(__file__).resolve().parent.parent.parent / "docs" / "api" / "endpoint-index.md" +OUTPUT = ( + Path(__file__).resolve().parent.parent.parent / "docs" / "api" / "endpoint-index.md" +) METHODS = ("get", "post", "put", "patch", "delete") HEADER = """# Endpoint index @@ -46,9 +48,7 @@ def main() -> None: schema = SchemaGenerator().get_schema(request=None, public=True) rows = [] for path in sorted(schema["paths"]): - methods = sorted( - m.upper() for m in schema["paths"][path] if m in METHODS - ) + methods = sorted(m.upper() for m in schema["paths"][path] if m in METHODS) if methods: rows.append(f"| `{path}` | {', '.join(methods)} |") OUTPUT.write_text(HEADER + "\n".join(rows) + "\n") diff --git a/backend/tasks/access.py b/backend/tasks/access.py index f1c5fdf9..f1c3a3da 100644 --- a/backend/tasks/access.py +++ b/backend/tasks/access.py @@ -30,13 +30,12 @@ from django.db.models import Q from django.http import Http404 from rest_framework.exceptions import PermissionDenied -from tasks.models import Task - # Re-exported, not redefined, the same arrangement `cases.access` uses. This # was an eleventh copy of the admin rule and the only one that had taken the # canonical name, so the guard test looking for private spellings walked past # it. It read `role == "ADMIN" or is_admin`, which is now one fact and not two. from common.permissions import is_org_admin # noqa: F401 +from tasks.models import Task _DENIED = "You do not have Permission to perform this action" diff --git a/backend/tasks/migrations/0009_task_kanban.py b/backend/tasks/migrations/0009_task_kanban.py index 10bc4bf8..fee412c3 100644 --- a/backend/tasks/migrations/0009_task_kanban.py +++ b/backend/tasks/migrations/0009_task_kanban.py @@ -1,7 +1,8 @@ # Generated manually for Task Kanban feature -import django.db.models.deletion import uuid + +import django.db.models.deletion from django.conf import settings from django.db import migrations, models diff --git a/backend/tasks/migrations/0011_task_custom_fields.py b/backend/tasks/migrations/0011_task_custom_fields.py index 315e0f3d..b195a3dc 100644 --- a/backend/tasks/migrations/0011_task_custom_fields.py +++ b/backend/tasks/migrations/0011_task_custom_fields.py @@ -2,7 +2,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("tasks", "0010_rename_task_status_kanban_idx_task_status_e6f973_idx_and_more"), ] diff --git a/backend/tasks/migrations/0012_task_is_sample.py b/backend/tasks/migrations/0012_task_is_sample.py index d9ff29f0..4aeaf52f 100644 --- a/backend/tasks/migrations/0012_task_is_sample.py +++ b/backend/tasks/migrations/0012_task_is_sample.py @@ -4,15 +4,17 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('tasks', '0011_task_custom_fields'), + ("tasks", "0011_task_custom_fields"), ] operations = [ migrations.AddField( - model_name='task', - name='is_sample', - field=models.BooleanField(default=False, help_text='True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.'), + model_name="task", + name="is_sample", + field=models.BooleanField( + default=False, + help_text="True only for demo rows created by common.packs.applier._apply_sample_data. Server-set exclusively, never expose this as a writable field on any serializer. It is the sole key common.packs.applier.clear_sample_data uses to decide what to delete, so a client-writable path here would let a user mark an arbitrary real record as sample and have it deleted.", + ), ), ] diff --git a/backend/tasks/migrations/0013_alter_taskpipeline_is_default.py b/backend/tasks/migrations/0013_alter_taskpipeline_is_default.py index be448625..4c44d7c5 100644 --- a/backend/tasks/migrations/0013_alter_taskpipeline_is_default.py +++ b/backend/tasks/migrations/0013_alter_taskpipeline_is_default.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("tasks", "0012_task_is_sample"), ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 24cddb10..57900481 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -8,7 +8,6 @@ from common.base import SAMPLE_DATA_HELP_TEXT, AssignableMixin, BaseModel from common.models import Org, Profile, Tags, Teams from contacts.models import Contact - # Cleanup notes: # - Removed 'created_on_arrow' property from Task model (frontend computes its own timestamps) diff --git a/backend/tasks/tests/test_custom_fields.py b/backend/tasks/tests/test_custom_fields.py index b77e22bb..23ccb544 100644 --- a/backend/tasks/tests/test_custom_fields.py +++ b/backend/tasks/tests/test_custom_fields.py @@ -25,9 +25,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) def _make_complexity_def(org, **overrides): @@ -189,9 +187,7 @@ class TestTaskUpdateWithCustomFields: @pg_only @pytest.mark.django_db class TestTaskListFilter: - def test_cf_filter_returns_matching( - self, admin_client, task_a, task_a2, org_a - ): + def test_cf_filter_returns_matching(self, admin_client, task_a, task_a2, org_a): _make_complexity_def(org_a) task_a.custom_fields = {"complexity": "epic"} task_a.save() @@ -207,9 +203,7 @@ class TestTaskListFilter: @pytest.mark.django_db class TestTaskDetailResponse: - def test_detail_includes_definitions_and_values( - self, admin_client, task_a, org_a - ): + def test_detail_includes_definitions_and_values(self, admin_client, task_a, org_a): _make_complexity_def(org_a) task_a.custom_fields = {"complexity": "trivial"} task_a.save() @@ -221,9 +215,7 @@ class TestTaskDetailResponse: keys = [d["key"] for d in body["custom_field_definitions"]] assert "complexity" in keys - def test_detail_excludes_inactive_definitions( - self, admin_client, task_a, org_a - ): + def test_detail_excludes_inactive_definitions(self, admin_client, task_a, org_a): _make_complexity_def(org_a) _make_complexity_def( org_a, key="legacy", field_type="text", options=None, is_active=False diff --git a/backend/tasks/tests/test_task_access.py b/backend/tasks/tests/test_task_access.py index 9ae11de4..fbde7fc1 100644 --- a/backend/tasks/tests/test_task_access.py +++ b/backend/tasks/tests/test_task_access.py @@ -37,6 +37,7 @@ import uuid import pytest from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone from accounts.models import Account from cases.models import Case @@ -44,7 +45,6 @@ from common.models import Attachments, Profile, User from leads.models import Lead from opportunity.models import Opportunity from tasks.models import Task -from django.utils import timezone def _task(org, creator, assignees=(), **kwargs): diff --git a/backend/tasks/tests/test_tasks_kanban.py b/backend/tasks/tests/test_tasks_kanban.py index 9e71c74b..b16ef1c2 100644 --- a/backend/tasks/tests/test_tasks_kanban.py +++ b/backend/tasks/tests/test_tasks_kanban.py @@ -11,9 +11,7 @@ def _set_rls(org): if connection.vendor != "postgresql": return with connection.cursor() as cursor: - cursor.execute( - "SELECT set_config('app.current_org', %s, false)", [str(org.id)] - ) + cursor.execute("SELECT set_config('app.current_org', %s, false)", [str(org.id)]) @pytest.mark.django_db @@ -29,13 +27,13 @@ class TestTaskPipeline: assert response.status_code == 201 data = response.json() assert data["name"] == "Development Pipeline" - assert TaskPipeline.objects.filter(name="Development Pipeline", org=org_a).exists() + assert TaskPipeline.objects.filter( + name="Development Pipeline", org=org_a + ).exists() def test_list_pipelines(self, admin_client, admin_user, org_a): _set_rls(org_a) - TaskPipeline.objects.create( - name="Pipeline 1", org=org_a, created_by=admin_user - ) + TaskPipeline.objects.create(name="Pipeline 1", org=org_a, created_by=admin_user) response = admin_client.get("/api/tasks/pipelines/") assert response.status_code == 200 data = response.json() @@ -94,9 +92,7 @@ class TestTaskPipeline: assert response.status_code == 200 assert response.json()["name"] == "New Name" - def test_update_pipeline_non_admin_forbidden( - self, user_client, admin_user, org_a - ): + def test_update_pipeline_non_admin_forbidden(self, user_client, admin_user, org_a): """Non-admin should not be able to update a pipeline.""" _set_rls(org_a) pipeline = TaskPipeline.objects.create( @@ -136,9 +132,7 @@ class TestTaskPipeline: assert response.status_code == 400 assert "Cannot delete pipeline" in response.json()["error"] - def test_delete_pipeline_non_admin_forbidden( - self, user_client, admin_user, org_a - ): + def test_delete_pipeline_non_admin_forbidden(self, user_client, admin_user, org_a): """Non-admin should not be able to delete a pipeline.""" _set_rls(org_a) pipeline = TaskPipeline.objects.create( @@ -172,9 +166,7 @@ class TestTaskStage: assert response.status_code == 201 data = response.json() assert data["name"] == "To Do" - assert TaskStage.objects.filter( - pipeline=pipeline, name="To Do" - ).exists() + assert TaskStage.objects.filter(pipeline=pipeline, name="To Do").exists() def test_update_stage(self, admin_client, admin_user, org_a): _set_rls(org_a) @@ -284,9 +276,7 @@ class TestTaskStage: response = user_client.delete(f"/api/tasks/stages/{stage.id}/") assert response.status_code == 403 - def test_delete_stage_with_tasks_returns_400( - self, admin_client, admin_user, org_a - ): + def test_delete_stage_with_tasks_returns_400(self, admin_client, admin_user, org_a): """Deleting a stage with tasks should fail with 400.""" _set_rls(org_a) pipeline = TaskPipeline.objects.create( @@ -328,9 +318,7 @@ class TestTaskStage: assert response.status_code == 400 assert "Invalid stage IDs" in response.json()["error"] - def test_reorder_stages_non_admin_forbidden( - self, user_client, admin_user, org_a - ): + def test_reorder_stages_non_admin_forbidden(self, user_client, admin_user, org_a): """Non-admin should not be able to reorder stages.""" _set_rls(org_a) pipeline = TaskPipeline.objects.create( diff --git a/backend/tasks/urls.py b/backend/tasks/urls.py index 78b81232..bb142968 100644 --- a/backend/tasks/urls.py +++ b/backend/tasks/urls.py @@ -10,8 +10,8 @@ from tasks.views.board_views import ( from tasks.views.kanban_views import ( TaskKanbanView, TaskMoveView, - TaskPipelineListCreateView, TaskPipelineDetailView, + TaskPipelineListCreateView, TaskStageCreateView, TaskStageDetailView, TaskStageReorderView, diff --git a/backend/tasks/views/kanban_views.py b/backend/tasks/views/kanban_views.py index 823c15dc..139353aa 100644 --- a/backend/tasks/views/kanban_views.py +++ b/backend/tasks/views/kanban_views.py @@ -8,7 +8,7 @@ from decimal import Decimal from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer from rest_framework import serializers, status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -20,8 +20,8 @@ from tasks.models import Task, TaskPipeline, TaskStage from tasks.serializer import ( TaskKanbanCardSerializer, TaskMoveSerializer, - TaskPipelineSerializer, TaskPipelineListSerializer, + TaskPipelineSerializer, TaskStageSerializer, ) diff --git a/backend/tasks/views/task_views.py b/backend/tasks/views/task_views.py index 5ee44b91..c04e4168 100644 --- a/backend/tasks/views/task_views.py +++ b/backend/tasks/views/task_views.py @@ -25,6 +25,13 @@ from common.models import ( Teams, ) from common.permissions import HasOrgContext +from common.serializer import ( + AttachmentsSerializer, + CommentSerializer, + CustomFieldDefinitionSerializer, + ProfileSerializer, + TeamsSerializer, +) from common.utils import create_attachment from common.validators import ( choice_list_param, @@ -33,13 +40,6 @@ from common.validators import ( uuid_list_param, uuid_param, ) -from common.serializer import ( - AttachmentsSerializer, - CommentSerializer, - CustomFieldDefinitionSerializer, - ProfileSerializer, - TeamsSerializer, -) from contacts.models import Contact from contacts.serializer import ContactSerializer from tasks import swagger_params @@ -107,9 +107,7 @@ class TaskListView(APIView, LimitOffsetPagination): queryset = queryset.filter(priority=params.get("priority")) assigned_to = uuid_list_param(params, "assigned_to") if assigned_to: - queryset = queryset.filter( - assigned_to__id__in=assigned_to - ).distinct() + queryset = queryset.filter(assigned_to__id__in=assigned_to).distinct() tags = uuid_list_param(params, "tags") if tags: queryset = queryset.filter(tags__id__in=tags).distinct() diff --git a/docs/contributing/code-style.md b/docs/contributing/code-style.md index 0c7c66d0..c3edc19e 100644 --- a/docs/contributing/code-style.md +++ b/docs/contributing/code-style.md @@ -21,38 +21,40 @@ That selects pycodestyle errors (`E`), pyflakes (`F`), and import sorting (`I`), ignores line-length (`E501`), so this project has decided not to enforce a line-length limit, not that it forgot to. -**But read this before running anything:** as of this writing, none of `black`, `isort`, or `ruff` -is declared as a dependency anywhere in `backend/pyproject.toml`, not in `[project.dependencies]`, -not in the `dev` or `docs` groups under `[dependency-groups]`, and not -pulled in transitively (`backend/uv.lock` has no entry for any of the three). `uv sync` on a fresh -clone will not install any of them, and `uv run black .` fails on a fresh clone with `error: Failed -to spawn: 'black' / Caused by: No such file or directory`. The same is true of `isort` and `ruff`. -CI does not run any of them either: -`.github/workflows/tests.yml`'s `backend-tests` job runs a migration check and `pytest`, and nothing -else. There is no lint or format step for Python anywhere in this repository's CI. In practice, -Python style in this project is currently enforced by human review, not tooling. +`ruff` is the only Python style tool here, and it is a real dev dependency, so `uv sync` installs +it and these commands work on a fresh clone: -Two things follow from that: +```bash +cd backend +uv run ruff check . # lint: E, F, I as ruff.toml selects them +uv run ruff check --fix . # and fix what is safely fixable +uv run ruff format . # format the tree +uv run ruff format --check . # or just report, which is what CI runs +``` -1. **Don't `uv add black isort ruff` to make the documented commands work.** Adding a dependency - changes `pyproject.toml` and re-locks `uv.lock` for every future `uv sync`, including CI's, so - it's a decision to make deliberately, in its own PR, not something to smuggle in while fixing an - unrelated bug. See [Development setup](development-setup.md#backend) for what `uv add` actually - does. -2. **The codebase does not currently conform to what these tools would enforce**, so even running - them read-only produces a large, unrelated diff: `uvx ruff check .`; `uvx` runs a tool from - PyPI in an ephemeral environment without touching this project's lockfile, reports on the order - of 150+ findings across the tree today, and `uvx black --check --diff .` reports on the order of - 170 files that would be reformatted. Neither number is a defect to fix in passing; it reflects - that these tools have not been run consistently against this codebase's history. If you want to - check your own changes without installing anything project-wide, scope `uvx` to the files you - touched (`uvx ruff check path/to/your_file.py`, `uvx black --check path/to/your_file.py`) rather - than the whole tree, running `black .` or `ruff check --fix .` across everything will reformat - files you didn't mean to touch and turn a focused PR into an unreviewable one. +**There is no `black` and no `isort`.** `ruff format` is a reimplementation of black and produces +the same output, and ruff's `I` rules do isort's job, so all three would be doing one job with two +of them redundant, and black and `ruff format` can disagree at the edges. Older docs and habits +name `uv run black . && uv run isort .`; neither is installed and neither should be added. -Match the surrounding file's conventions by eye, 4-space indentation, double-quoted strings, -Django/DRF import grouping (standard library, then third-party, then local `common`/app imports), -the way `ruff.toml`'s settings describe, even without the tool enforcing it for you. +**CI runs both, and both must pass.** `.github/workflows/tests.yml` runs `uv run ruff check .` and +`uv run ruff format --check .` as hard steps, not warnings. `uv run` rather than `uvx` on purpose: +ruff is pinned in `uv.lock`, so your machine and CI check against one version and a ruff release +cannot turn the build red overnight. + +**The tree conforms.** It was formatted in full on 2026-08-07 (205 files) with imports sorted in +112 more, so `ruff format .` on a clean checkout changes nothing and your diff stays your diff. +That is the point of formatting everything at once: before it, running the formatter across the +tree turned any focused PR into an unreviewable one, which is why the old advice here was to scope +`uvx` to the files you touched. That advice no longer applies. Run the commands above on the whole +tree and commit only what you meant to change. + +One thing worth knowing before you run `--fix` over code you did not write: a `# noqa` binds to the +line ruff reports the diagnostic on, so a suppression that has drifted onto the next line, or that +names the wrong rule code, silently suppresses nothing, and the autofix will then delete what it +was protecting. `common/models.py`'s `SecurityAuditLog` import is the live example: it exists only +so Django discovers the model for migrations, and its `# noqa: F401,E402` has to sit on the same +line as the imported name. ## Frontend @@ -64,9 +66,8 @@ pnpm run format # prettier --write . pnpm run check # svelte-kit sync && svelte-check --tsconfig ./jsconfig.json ``` -Unlike the backend, these tools are installed (`prettier`, `eslint`, `svelte-check`, and their -plugins are all in `frontend/package.json`'s `devDependencies`) and CI runs all three, but not -symmetrically: `.github/workflows/tests.yml`'s `frontend-checks` job runs the Prettier check with a +These tools are installed (`prettier`, `eslint`, `svelte-check`, and their plugins are all in +`frontend/package.json`'s `devDependencies`) and CI runs all three, but not symmetrically: `.github/workflows/tests.yml`'s `frontend-checks` job runs the Prettier check with a `|| echo "::warning::..."` fallback, so a formatting difference shows up as a CI **warning**, not a failed build. The ESLint step (`pnpm eslint .`) has no such fallback. A lint error fails the job. `pnpm check` (svelte-check) and `pnpm build` run unconditionally after that and also fail the job on @@ -84,8 +85,8 @@ flutter analyze --no-fatal-infos `mobile/analysis_options.yaml` includes `package:flutter_lints/flutter.yaml` with no project-specific rules added or removed. Neither of this repository's two GitHub Actions workflows -(`tests.yml`, `codeql-analysis.yml`) mentions Flutter, Dart, or `mobile/` at all, so, like the -backend's formatters, nothing in CI enforces Dart formatting or lint cleanliness; running these -commands yourself before opening a PR is the only check that happens. See +(`tests.yml`, `codeql-analysis.yml`) mentions Flutter, Dart, or `mobile/` at all, so nothing in CI +enforces Dart formatting or lint cleanliness. Unlike the backend, which now gates on ruff, running +these commands yourself before opening a PR is the only check that happens. See [Build and configure](../mobile/build-and-configure.md#tests-and-analysis) for the full command set including `flutter test`. diff --git a/frontend/src/routes/api/cases/analytics/[metric]/+server.js b/frontend/src/routes/api/cases/analytics/[metric]/+server.js index dc2c5e99..a91701c7 100644 --- a/frontend/src/routes/api/cases/analytics/[metric]/+server.js +++ b/frontend/src/routes/api/cases/analytics/[metric]/+server.js @@ -4,17 +4,35 @@ import { apiRequest } from '$lib/api-helpers.js'; const ALLOWED = new Set(['frt', 'mttr', 'backlog', 'agents', 'sla', 'drilldown']); /** Generic GET proxy for the analytics endpoints, passes the query string - * through verbatim so filter handling stays on the backend. */ + * through verbatim so filter handling stays on the backend. + * + * The upstream status is passed through too. `apiRequest` throws on any + * non-2xx, and an uncaught throw in a `+server.js` handler is a 500 whatever + * the cause was, so a `?from=banana` the API correctly refuses with a 400 + * reached the caller as "internal server error" with the field name and the + * reason both discarded. The sibling `export/+server.js` already forwards + * `upstream.status`; this route was the one that did not. */ /** @type {import('./$types').RequestHandler} */ export async function GET({ params, url, cookies, locals }) { if (!ALLOWED.has(params.metric)) { return json({ error: 'Unknown metric' }, { status: 404 }); } const qs = url.searchParams.toString(); - const data = await apiRequest( - `/cases/analytics/${params.metric}/${qs ? `?${qs}` : ''}`, - {}, - { cookies, org: locals?.org } - ); - return json(data); + try { + const data = await apiRequest( + `/cases/analytics/${params.metric}/${qs ? `?${qs}` : ''}`, + {}, + { cookies, org: locals?.org } + ); + return json(data); + } catch (err) { + // `apiRequest` attaches the upstream status and its parsed body. A failure + // with neither is a transport problem rather than a rejection, which is a + // 502: the proxy reached nobody, so claiming the caller was at fault would + // be a guess. + const failure = /** @type {{ status?: number, body?: unknown, message?: string }} */ (err); + return json(failure?.body ?? { error: failure?.message ?? 'Upstream error' }, { + status: failure?.status ?? 502 + }); + } } diff --git a/frontend/src/routes/api/cases/analytics/[metric]/server.test.js b/frontend/src/routes/api/cases/analytics/[metric]/server.test.js new file mode 100644 index 00000000..4dea8a0b --- /dev/null +++ b/frontend/src/routes/api/cases/analytics/[metric]/server.test.js @@ -0,0 +1,141 @@ +import { describe, it, expect, vi } from 'vitest'; + +/** + * The analytics proxy forwards the upstream status. + * + * `apiRequest` throws on any non-2xx, and an uncaught throw inside a + * `+server.js` handler becomes a 500 whatever the real cause was. So a + * `?from=banana`, which the API refuses with a 400 naming the field and the + * reason, reached the caller as "internal server error" with both discarded. + * + * This file imports the route module directly. `vitest.config.js` warns that + * route modules are outside this harness, but that warning is about + * `+page.server.js`, which pulls `$app/forms` in through its actions. A + * `+server.js` importing only `@sveltejs/kit` and `$lib` resolves fine, and + * `./$types` is a JSDoc reference rather than a runtime import. + */ + +const apiRequest = vi.fn(); +vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...args) => apiRequest(...args) })); + +const { GET } = await import('./+server.js'); + +/** + * The handler arg SvelteKit would build, with only the parts this route reads. + * + * Typed `any` deliberately. A real `RequestEvent` carries ten more properties + * (`fetch`, `request`, `getClientAddress`, `platform` and the rest) that this + * route never touches, and building them would be scaffolding that asserts + * nothing. `svelte-check` runs over this file, so without the annotation it + * reports every call site as a type error. + * + * @returns {any} + */ +function event(metric, query = '') { + return { + params: { metric }, + url: new URL(`http://localhost/api/cases/analytics/${metric}/${query}`), + cookies: { get: () => 'token' }, + locals: { org: { id: 'org-1' } } + }; +} + +/* No `beforeEach` resetting the mock. Both `mockReset` and `mockClear` make + vitest report the previous test's rejected promise as unhandled, because + clearing the recorded results loses the bookkeeping that says it was awaited. + Every test sets its own implementation, and the two that assert on call + history read `lastCall` or a delta, so accumulated history is harmless. */ + +/** An error shaped the way `api-helpers.js` throws one: message plus the two + * properties it attaches, which are what the proxy reads back off it. */ +function upstreamFailure(status, body) { + const err = /** @type {Error & { status: number, body: unknown }} */ ( + new Error('upstream said no') + ); + err.status = status; + err.body = body; + return err; +} + +describe('the upstream status survives the proxy', () => { + it('forwards a 400 as a 400', async () => { + apiRequest.mockRejectedValue(upstreamFailure(400, { from: ["'banana' is not a valid date."] })); + + const response = await GET(event('frt', '?from=banana')); + + expect(response.status).toBe(400); + }); + + it('forwards the body, so the caller learns which field was wrong', async () => { + const body = { from: ["'banana' is not a valid date."] }; + apiRequest.mockRejectedValue(upstreamFailure(400, body)); + + const response = await GET(event('frt', '?from=banana')); + + expect(await response.json()).toEqual(body); + }); + + it('forwards a 403 as a 403, not as a 500', async () => { + apiRequest.mockRejectedValue(upstreamFailure(403, { error: 'Admin access required' })); + + const response = await GET(event('agents')); + + expect(response.status).toBe(403); + }); + + it('forwards a real 500 as a 500', async () => { + apiRequest.mockRejectedValue(upstreamFailure(500, {})); + + const response = await GET(event('frt')); + + expect(response.status).toBe(500); + }); + + it('answers 502 when the failure carries no status at all', async () => { + /* A transport error rather than a rejection: the proxy reached nobody, so + blaming the caller with a 4xx would be a guess. */ + apiRequest.mockRejectedValue(new Error('fetch failed')); + + const response = await GET(event('frt')); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: 'fetch failed' }); + }); +}); + +describe('the success path is unchanged', () => { + it('returns the upstream payload with a 200', async () => { + apiRequest.mockResolvedValue({ series: [1, 2, 3] }); + + const response = await GET(event('frt', '?from=2026-08-01')); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ series: [1, 2, 3] }); + }); + + it('passes the query string through verbatim', async () => { + apiRequest.mockResolvedValue({}); + + await GET(event('backlog', '?from=2026-08-01&to=2026-08-07&team=t1')); + + expect(apiRequest.mock.lastCall?.[0]).toBe( + '/cases/analytics/backlog/?from=2026-08-01&to=2026-08-07&team=t1' + ); + }); +}); + +describe('an unknown metric is refused before any call', () => { + it('answers 404', async () => { + const response = await GET(event('banana')); + + expect(response.status).toBe(404); + }); + + it('does not reach the API', async () => { + const before = apiRequest.mock.calls.length; + + await GET(event('banana')); + + expect(apiRequest.mock.calls.length).toBe(before); + }); +});