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:
parent
f4484eaf4b
commit
bd79a7a4cc
254 changed files with 4180 additions and 2044 deletions
31
.github/workflows/tests.yml
vendored
31
.github/workflows/tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import pytest
|
|||
|
||||
from business_hours.calendar import add_business_hours
|
||||
|
||||
|
||||
_UTC = ZoneInfo("UTC")
|
||||
_NY = ZoneInfo("America/New_York")
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import logging
|
|||
from typing import Optional
|
||||
|
||||
from contacts.models import Contact
|
||||
|
||||
from .parser import ParsedEmail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0017_sla_pause"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0019_csatsurvey"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ def backfill_mailbox(apps, schema_editor):
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0023_case_merge_record"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0024_emailmessage_mailbox"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("cases", "0026_case_is_sample"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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>'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}/")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for the bulk update / bulk delete Case endpoints."""
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import rls_org
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from django.utils import timezone
|
|||
|
||||
from business_hours.models import BusinessCalendar
|
||||
|
||||
|
||||
_UTC = ZoneInfo("UTC")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", [])]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ class AuditLogger:
|
|||
event_type,
|
||||
user_id,
|
||||
org_id,
|
||||
request_info.get('ip_address'),
|
||||
request_info.get("ip_address"),
|
||||
success,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0018_add_notification"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0020_org_csat_enabled"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("common", "0024_alter_customfielddefinition_target_model"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue