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.
This commit is contained in:
Ashwin 2026-08-07 15:58:35 +05:30
parent f4484eaf4b
commit bd79a7a4cc
254 changed files with 4180 additions and 2044 deletions

View file

@ -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

View file

@ -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

View file

@ -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.",
),
),
]

View file

@ -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.",
),
),
]

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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!")

View file

@ -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

View file

@ -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")

View file

@ -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:

View file

@ -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(

View file

@ -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."}

View file

@ -7,7 +7,6 @@ import pytest
from business_hours.calendar import add_business_hours
_UTC = ZoneInfo("UTC")
_NY = ZoneInfo("America/New_York")

View file

@ -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):

View file

@ -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)

View file

@ -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

View file

@ -8,6 +8,7 @@ import logging
from typing import Optional
from contacts.models import Contact
from .parser import ParsedEmail
logger = logging.getLogger(__name__)

View file

@ -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

View file

@ -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.

View file

@ -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"}

View file

@ -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 #<short-id>]` where short-id is the first

View file

@ -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):

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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),

View file

@ -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.",
),
),
]

View file

@ -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),

View file

@ -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),

View file

@ -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),
]

View file

@ -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",
),
),
]

View file

@ -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),
]

View file

@ -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),
),
]

View file

@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("cases", "0017_sla_pause"),
]

View file

@ -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),

View file

@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("cases", "0019_csatsurvey"),
]

View file

@ -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),

View file

@ -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),
]

View file

@ -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),
),
]

View file

@ -24,7 +24,6 @@ def backfill_mailbox(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("cases", "0023_case_merge_record"),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("cases", "0024_emailmessage_mailbox"),
]

View file

@ -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.",
),
),
]

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("cases", "0026_case_is_sample"),
]

View file

@ -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,
),
),
]

View file

@ -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()
)

View file

@ -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."},

View file

@ -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

View file

@ -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)

View file

@ -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))

View file

@ -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))

View file

@ -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

View file

@ -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"),

View file

@ -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'<p>Hi {contact.first_name or "there"},</p>'
f"<p>Hi {contact.first_name or 'there'},</p>"
f'<p>How did we do on case "{case.name}"?</p>'
f'<p><a href="{link}">Rate your experience</a></p>'
)

View file

@ -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

View file

@ -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),

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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}/")

View file

@ -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()

View file

@ -1,6 +1,7 @@
"""Tests for the bulk update / bulk delete Case endpoints."""
import pytest
from conftest import rls_org

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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/"

View file

@ -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.

View file

@ -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

View file

@ -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]

View file

@ -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"

View file

@ -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},

View file

@ -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)

View file

@ -9,7 +9,6 @@ from django.utils import timezone
from business_hours.models import BusinessCalendar
_UTC = ZoneInfo("UTC")

View file

@ -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):

View file

@ -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(

View file

@ -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", [])]

View file

@ -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()

View file

@ -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).

View file

@ -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

View file

@ -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/<str:token>/", PublicCsatView.as_view(), name="public_csat"
),
path("public/csat/<str:token>/", PublicCsatView.as_view(), name="public_csat"),
]

View file

@ -161,7 +161,7 @@ class AuditLogger:
event_type,
user_id,
org_id,
request_info.get('ip_address'),
request_info.get("ip_address"),
success,
)

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"),
]

View file

@ -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,
),
),
]

View file

@ -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",
),
),
]

View file

@ -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),

View file

@ -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),

View file

@ -7,7 +7,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0018_add_notification"),
]

View file

@ -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),
),
]

View file

@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0020_org_csat_enabled"),
]

View file

@ -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,
),
),
]

View file

@ -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,
),
),
]

View file

@ -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,
),
),
]

View file

@ -2,7 +2,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("common", "0024_alter_customfielddefinition_target_model"),
]

View file

@ -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"
),
),
]

Some files were not shown because too many files have changed in this diff Show more