|
51 | 51 | from contacts.serializer import ContactSerializer |
52 | 52 |
|
53 | 53 |
|
| 54 | +_ALLOWED_CASE_ORDERINGS = frozenset( |
| 55 | + { |
| 56 | + "-created_at", |
| 57 | + "created_at", |
| 58 | + "-priority", |
| 59 | + "priority", |
| 60 | + "-id", |
| 61 | + "id", |
| 62 | + "-name", |
| 63 | + "name", |
| 64 | + } |
| 65 | +) |
| 66 | + |
| 67 | + |
| 68 | +def apply_case_list_filters(queryset, params): |
| 69 | + """Apply the case-list query-param filters to ``queryset``. |
| 70 | +
|
| 71 | + Shared between CaseListView and WatchingListView so both endpoints accept |
| 72 | + the same filter chips from the mobile client. Caller is responsible for |
| 73 | + the base scope (org membership, watcher allowance, etc.) — this only |
| 74 | + layers in user-supplied filters. |
| 75 | + """ |
| 76 | + if not params: |
| 77 | + return queryset |
| 78 | + |
| 79 | + if params.get("name"): |
| 80 | + queryset = queryset.filter(name__icontains=params.get("name")) |
| 81 | + # Status can be a single value or a list. Mobile uses the list form for |
| 82 | + # its Open/Closed quick chips (Open = New, Assigned, Pending). |
| 83 | + status_values = [s for s in params.getlist("status") if s] |
| 84 | + if len(status_values) > 1: |
| 85 | + queryset = queryset.filter(status__in=status_values) |
| 86 | + elif status_values: |
| 87 | + queryset = queryset.filter(status=status_values[0]) |
| 88 | + if params.get("priority"): |
| 89 | + queryset = queryset.filter(priority=params.get("priority")) |
| 90 | + if params.get("account"): |
| 91 | + queryset = queryset.filter(account=params.get("account")) |
| 92 | + if params.get("case_type"): |
| 93 | + queryset = queryset.filter(case_type=params.get("case_type")) |
| 94 | + if params.getlist("assigned_to"): |
| 95 | + queryset = queryset.filter( |
| 96 | + assigned_to__id__in=params.getlist("assigned_to") |
| 97 | + ).distinct() |
| 98 | + if params.get("tags"): |
| 99 | + queryset = queryset.filter(tags__id__in=params.getlist("tags")).distinct() |
| 100 | + if params.get("search"): |
| 101 | + search = params.get("search") |
| 102 | + queryset = queryset.filter( |
| 103 | + Q(name__icontains=search) | Q(description__icontains=search) |
| 104 | + ) |
| 105 | + if params.get("created_at__gte"): |
| 106 | + queryset = queryset.filter(created_at__gte=params.get("created_at__gte")) |
| 107 | + if params.get("created_at__lte"): |
| 108 | + queryset = queryset.filter(created_at__lte=params.get("created_at__lte")) |
| 109 | + if params.get("sla_breached") == "true": |
| 110 | + # Wall-clock approximation matching the mobile card's |
| 111 | + # `isFirstResponseSlaBreached` getter — `Case.is_sla_*_breached` uses |
| 112 | + # business hours, but the badges on both clients compute from |
| 113 | + # created_at + hours, so this filter mirrors what the user actually |
| 114 | + # sees on the row. Postgres-specific (INTERVAL). |
| 115 | + queryset = queryset.extra( |
| 116 | + where=[ |
| 117 | + "(first_response_at IS NULL " |
| 118 | + "AND sla_first_response_hours IS NOT NULL " |
| 119 | + "AND created_at + sla_first_response_hours " |
| 120 | + "* INTERVAL '1 hour' < NOW()) " |
| 121 | + "OR (resolved_at IS NULL " |
| 122 | + "AND sla_resolution_hours IS NOT NULL " |
| 123 | + "AND created_at + sla_resolution_hours " |
| 124 | + "* INTERVAL '1 hour' < NOW())" |
| 125 | + ] |
| 126 | + ) |
| 127 | + # Custom-field filters: ?cf_<key>=<value> -> custom_fields contains pair. |
| 128 | + for raw_key, raw_value in params.items(): |
| 129 | + if raw_key.startswith("cf_") and raw_value: |
| 130 | + cf_key = raw_key[3:] |
| 131 | + if cf_key: |
| 132 | + queryset = queryset.filter(custom_fields__contains={cf_key: raw_value}) |
| 133 | + # Ordering — whitelisted so callers can't sort on arbitrary cols. |
| 134 | + ordering = params.get("ordering") |
| 135 | + if ordering in _ALLOWED_CASE_ORDERINGS: |
| 136 | + queryset = queryset.order_by(ordering, "-id") |
| 137 | + |
| 138 | + return queryset |
| 139 | + |
| 140 | + |
54 | 141 | class CaseListView(APIView, LimitOffsetPagination): |
55 | 142 | permission_classes = (IsAuthenticated, HasOrgContext) |
56 | 143 | model = Case |
@@ -98,43 +185,7 @@ def get_context_data(self, **kwargs): |
98 | 185 | ).distinct() |
99 | 186 | profiles = profiles.filter(role="ADMIN") |
100 | 187 |
|
101 | | - if params: |
102 | | - if params.get("name"): |
103 | | - queryset = queryset.filter(name__icontains=params.get("name")) |
104 | | - if params.get("status"): |
105 | | - queryset = queryset.filter(status=params.get("status")) |
106 | | - if params.get("priority"): |
107 | | - queryset = queryset.filter(priority=params.get("priority")) |
108 | | - if params.get("account"): |
109 | | - queryset = queryset.filter(account=params.get("account")) |
110 | | - if params.get("case_type"): |
111 | | - queryset = queryset.filter(case_type=params.get("case_type")) |
112 | | - if params.getlist("assigned_to"): |
113 | | - queryset = queryset.filter( |
114 | | - assigned_to__id__in=params.getlist("assigned_to") |
115 | | - ).distinct() |
116 | | - if params.get("tags"): |
117 | | - queryset = queryset.filter( |
118 | | - tags__id__in=params.getlist("tags") |
119 | | - ).distinct() |
120 | | - if params.get("search"): |
121 | | - queryset = queryset.filter(name__icontains=params.get("search")) |
122 | | - if params.get("created_at__gte"): |
123 | | - queryset = queryset.filter( |
124 | | - created_at__gte=params.get("created_at__gte") |
125 | | - ) |
126 | | - if params.get("created_at__lte"): |
127 | | - queryset = queryset.filter( |
128 | | - created_at__lte=params.get("created_at__lte") |
129 | | - ) |
130 | | - # Custom-field filters: ?cf_<key>=<value> -> custom_fields contains pair. |
131 | | - for raw_key, raw_value in params.items(): |
132 | | - if raw_key.startswith("cf_") and raw_value: |
133 | | - cf_key = raw_key[3:] |
134 | | - if cf_key: |
135 | | - queryset = queryset.filter( |
136 | | - custom_fields__contains={cf_key: raw_value} |
137 | | - ) |
| 188 | + queryset = apply_case_list_filters(queryset, params) |
138 | 189 |
|
139 | 190 | context = {} |
140 | 191 |
|
|
0 commit comments