Skip to content

Commit 571d242

Browse files
authored
Merge pull request #688 from MicroPyramid/dev
Dev
2 parents d98cfb1 + eaab997 commit 571d242

71 files changed

Lines changed: 14123 additions & 2874 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/accounts/tests/test_accounts_api.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,28 @@ def test_filter_by_assigned_to(self, admin_client, org_a, admin_profile):
685685
names = [a["name"] for a in response.json()["active_accounts"]["open_accounts"]]
686686
assert "Assigned Acct" in names
687687

688+
def test_filter_by_tags(self, admin_client, org_a):
689+
"""?tags=<uuid> returns only accounts carrying that tag.
690+
691+
Regression: previously the filter used `tags__in=params.get("tags")`,
692+
iterating the UUID string char-by-char. Now uses
693+
`tags__id__in=params.getlist("tags")`.
694+
"""
695+
tag_vip = Tags.objects.create(name="VIP", org=org_a)
696+
tag_cold = Tags.objects.create(name="Cold", org=org_a)
697+
tagged = Account.objects.create(name="Tagged Acct", org=org_a)
698+
tagged.tags.add(tag_vip)
699+
other = Account.objects.create(name="Other Acct", org=org_a)
700+
other.tags.add(tag_cold)
701+
Account.objects.create(name="Untagged Acct", org=org_a)
702+
703+
response = admin_client.get(f"/api/accounts/?tags={tag_vip.id}")
704+
assert response.status_code == 200
705+
names = {
706+
a["name"] for a in response.json()["active_accounts"]["open_accounts"]
707+
}
708+
assert names == {"Tagged Acct"}
709+
688710
def test_filter_by_created_at_range(self, admin_client, org_a):
689711
"""Filter accounts by created_at date range."""
690712
Account.objects.create(name="Date Range Acct", org=org_a)

backend/accounts/views.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ def get_context_data(self, **kwargs):
7777
queryset = queryset.filter(city__icontains=params.get("city"))
7878
if params.get("industry"):
7979
queryset = queryset.filter(industry__icontains=params.get("industry"))
80-
if params.get("tags"):
81-
queryset = queryset.filter(tags__in=params.get("tags")).distinct()
80+
if params.getlist("tags"):
81+
queryset = queryset.filter(
82+
tags__id__in=params.getlist("tags")
83+
).distinct()
8284
if params.getlist("assigned_to"):
8385
queryset = queryset.filter(
8486
assigned_to__id__in=params.getlist("assigned_to")

backend/cases/views.py

Lines changed: 88 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,93 @@
5151
from contacts.serializer import ContactSerializer
5252

5353

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+
54141
class CaseListView(APIView, LimitOffsetPagination):
55142
permission_classes = (IsAuthenticated, HasOrgContext)
56143
model = Case
@@ -98,43 +185,7 @@ def get_context_data(self, **kwargs):
98185
).distinct()
99186
profiles = profiles.filter(role="ADMIN")
100187

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

139190
context = {}
140191

backend/cases/watcher_views.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,18 @@ def get(self, request, pk, *args, **kwargs):
9999
class WatchingListView(APIView):
100100
"""GET /api/cases/watching/
101101
102-
Returns the list of cases the current profile watches. Lightweight
103-
payload — the full case detail is fetched separately when the user opens
104-
one. Reusing `Case.serializer.CaseSerializer` for parity with the main
105-
list endpoint.
102+
Returns the list of cases the current profile watches. Accepts the same
103+
query-param filters as `CaseListView` (status, priority, account,
104+
case_type, assigned_to[], tags[], search, created_at__gte/lte,
105+
sla_breached, ordering) so the mobile filter chips work identically in
106+
watching mode.
106107
"""
107108

108109
permission_classes = (IsAuthenticated,)
109110

110111
def get(self, request, *args, **kwargs):
111112
from cases.serializer import CaseSerializer
113+
from cases.views import apply_case_list_filters
112114

113115
cases = (
114116
Case.objects.filter(
@@ -117,6 +119,7 @@ def get(self, request, *args, **kwargs):
117119
.order_by("-created_at")
118120
.distinct()
119121
)
122+
cases = apply_case_list_filters(cases, request.query_params)
120123
return Response(
121124
{
122125
"cases": CaseSerializer(cases, many=True).data,

backend/common/manager.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ def create_user(self, email, password=None, **extra_fields):
66
if not email:
77
raise ValueError("The Email field must be set")
88
email = self.normalize_email(email)
9+
# Mirror the User.save() invariant so callers reading the manager
10+
# in isolation see the contract: name defaults to email-local-part
11+
# when not provided.
12+
if not extra_fields.get("name"):
13+
extra_fields["name"] = email.split("@", 1)[0][:255]
914
user = self.model(email=email, **extra_fields)
1015
user.set_password(password)
1116
user.save(using=self._db)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 6.0.5 on 2026-05-14 21:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('common', '0025_magic_link_token_otp'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='user',
15+
name='name',
16+
field=models.CharField(blank=True, default='', max_length=255, verbose_name='name'),
17+
),
18+
]

backend/common/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class User(AbstractBaseUser, PermissionsMixin):
3333
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
3434
)
3535
email = models.EmailField(_("email address"), blank=True, unique=True)
36+
name = models.CharField(_("name"), max_length=255, blank=True, default="")
3637
profile_pic = models.CharField(max_length=1000, null=True, blank=True)
3738
activation_key = models.CharField(max_length=150, null=True, blank=True)
3839
key_expires = models.DateTimeField(null=True, blank=True)
@@ -50,6 +51,14 @@ class Meta:
5051
db_table = "users"
5152
ordering = ("-is_active",)
5253

54+
def save(self, *args, **kwargs):
55+
# On first save only, fall back to the email local-part when no name
56+
# was supplied — keeps `name` non-empty without overwriting later
57+
# edits (PATCHing name to "" leaves it empty by user intent).
58+
if self._state.adding and not self.name and self.email:
59+
self.name = self.email.split("@", 1)[0][:255]
60+
super().save(*args, **kwargs)
61+
5362
def __str__(self):
5463
return self.email
5564

@@ -224,6 +233,7 @@ def user_details(self):
224233
return {
225234
"email": self.user.email,
226235
"id": self.user.id,
236+
"name": self.user.name,
227237
"is_active": self.user.is_active,
228238
"profile_pic": self.user.profile_pic,
229239
"last_login": self.user.last_login,

backend/common/serializer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ def __init__(self, *args, **kwargs):
460460
class UserSerializer(serializers.ModelSerializer):
461461
class Meta:
462462
model = User
463-
fields = ["id", "email", "profile_pic"]
463+
fields = ["id", "email", "name", "profile_pic"]
464464

465465

466466
class ProfileSerializer(serializers.ModelSerializer):

backend/common/tests/test_auth.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,11 @@ def test_successful_new_user(self, mock_request_cls, mock_verify, unauthenticate
465465
)
466466
assert response.status_code == status.HTTP_200_OK
467467
assert "JWTtoken" in response.data
468+
# Refresh token must be present so the mobile client can refresh the
469+
# 1-hour access token before the user picks an org (which is the only
470+
# other place that would mint a refresh token via OrgSwitchView).
471+
assert "refresh_token" in response.data
472+
assert response.data["refresh_token"]
468473
assert response.data["user"]["email"] == "mobileuser@example.com"
469474
assert User.objects.filter(email="mobileuser@example.com").exists()
470475

backend/common/tests/test_organizations.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,43 @@ def test_patch_profile_phone(self, admin_client, admin_profile):
167167
admin_profile.refresh_from_db()
168168
assert admin_profile.phone == "+9876543210"
169169

170+
def test_patch_profile_name(self, admin_client, admin_profile):
171+
"""Update name on User via PATCH /api/profile/."""
172+
response = admin_client.patch(
173+
self.url,
174+
{"name": "Alex Carter"},
175+
format="json",
176+
)
177+
assert response.status_code == status.HTTP_200_OK
178+
admin_profile.user.refresh_from_db()
179+
assert admin_profile.user.name == "Alex Carter"
180+
181+
def test_patch_profile_name_trims_whitespace(self, admin_client, admin_profile):
182+
"""Name input is trimmed before save."""
183+
response = admin_client.patch(
184+
self.url,
185+
{"name": " Alex Carter "},
186+
format="json",
187+
)
188+
assert response.status_code == status.HTTP_200_OK
189+
admin_profile.user.refresh_from_db()
190+
assert admin_profile.user.name == "Alex Carter"
191+
192+
def test_patch_profile_name_and_phone_together(
193+
self, admin_client, admin_profile
194+
):
195+
"""Both fields update in a single PATCH."""
196+
response = admin_client.patch(
197+
self.url,
198+
{"name": "Alex Carter", "phone": "+1234567890"},
199+
format="json",
200+
)
201+
assert response.status_code == status.HTTP_200_OK
202+
admin_profile.refresh_from_db()
203+
admin_profile.user.refresh_from_db()
204+
assert admin_profile.user.name == "Alex Carter"
205+
assert admin_profile.phone == "+1234567890"
206+
170207

171208
@pytest.mark.django_db
172209
class TestOrgSettingsView:

0 commit comments

Comments
 (0)