fix api contact search

This commit is contained in:
javad committed 2026-09-30 11:09:12 +03:30
1 parent 04d2bc957f
commit 39e64854b8
4 files changed
+124 -33

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# خروجی مخاطبان برای Phone
این برنامه endpoint خواندنی `GET /api/phone-contacts?search=...&limit=50` را مطابق قرارداد ابتدای `EXTERNAL_CONTACT_APIS.md` ارائه می‌کند. پاسخ همیشه شیء JSON با کلید `contacts` است. هر مخاطب فعال دارای پروفایل و نام، با شماره همراه در قالب محلی `09...` برگردانده می‌شود. جست‌وجو در نام، نام خانوادگی، نام سازمان و شماره ثبت‌شده انجام می‌شود. سقف پاسخ ۵۰ مخاطب است.
این برنامه endpoint خواندنی `GET /api/phone-contacts?search=...&limit=50` را مطابق قرارداد ابتدای `EXTERNAL_CONTACT_APIS.md` ارائه می‌کند. پاسخ همیشه شیء JSON با کلید `contacts` است. هر مخاطب فعال دارای پروفایل و نام، با شمارهٔ ثبت‌شده‌اش برگردانده می‌شود. هر نتیجه فیلد مستقیم `mobile` و آرایهٔ سازگار با Phone یعنی `phones` را دارد. جست‌وجو با نام و با شمارهٔ کامل در قالب‌های `0`، `+98` و `0098` برای هر پیش‌شماره‌ای انجام می‌شود. مدل فعلی فقط شمارهٔ همراه را در مسیر عادی ثبت می‌کند؛ شمارهٔ ثابت تنها اگر از قبل در داده موجود باشد قابل جست‌وجو است. سقف پاسخ ۵۰ مخاطب است. جزئیات در `docs/PHONE_CONTACTS_API.md` آمده است.
## راه‌اندازی
+45
View File
@@ -39,6 +39,7 @@ def test_contact_feed_authentication_search_and_contract(client):
assert response.status_code == 200
assert response.json() == {"contacts": [{
"name": "علی رضایی",
"mobile": "09123456789",
"phones": [{"type": "همراه", "number": "09123456789"}],
}]}
assert response["Cache-Control"] == "no-store"
@@ -47,6 +48,50 @@ def test_contact_feed_authentication_search_and_contract(client):
assert client.post(URL, **authorize(client)).status_code == 405
@pytest.mark.parametrize("search", [
"09123456789", "+989123456789", "00989123456789",
"۰۹۱۲۳۴۵۶۷۸۹", "۰۹۱۲-۳۴۵-۶۷۸۹",
])
def test_contact_feed_finds_same_mobile_in_supported_formats(client, search):
key = PhoneContactApiKey()
key.set_key(FIRST_KEY)
key.save()
make_contact(mobile="09123456789", first_name="علی", last_name="رضایی")
make_contact(mobile="09123456780", first_name="مریم")
response = client.get(URL, {"search": search}, **authorize(client))
assert response.status_code == 200
assert response.json() == {"contacts": [{
"name": "علی رضایی",
"mobile": "09123456789",
"phones": [{"type": "همراه", "number": "09123456789"}],
}]}
assert client.get(URL, {"search": "09120000000"}, **authorize(client)).json() == {"contacts": []}
@pytest.mark.parametrize("stored,search,display", [
("+989131234567", "09131234567", "09131234567"),
("+989131234567", "00989131234567", "09131234567"),
("+982112345678", "02112345678", "02112345678"),
("02112345678", "+982112345678", "02112345678"),
("+982112345678", "0098-21-1234-5678", "02112345678"),
])
def test_contact_feed_matches_any_iranian_prefix_without_mobile_area_code_assumption(client, stored, search, display):
key = PhoneContactApiKey()
key.set_key(FIRST_KEY)
key.save()
profile = make_contact(mobile="09123456789", first_name="علی", last_name="رضایی")
get_user_model().objects.filter(pk=profile.user_id).update(mobile=stored)
response = client.get(URL, {"search": search}, **authorize(client))
assert response.status_code == 200
assert response.json() == {"contacts": [{
"name": "علی رضایی",
"mobile": display,
"phones": [{"type": "همراه" if display.startswith("09") else "تلفن", "number": display}],
}]}
def test_key_rotation_and_disabling_take_effect_immediately(client):
key = PhoneContactApiKey()
key.set_key(FIRST_KEY)
+66 -26
View File
@@ -17,12 +17,74 @@ def _dialable_number(value):
return number if 0 < len(number) <= 64 and re.fullmatch(r"[0-9+*#]+", number) else ""
def _canonical_number(value):
number = _dialable_number(value)
if not number or not re.fullmatch(r"\+[0-9]+|[0-9*#]+", number):
return ""
if number.isdigit() and number.startswith("00") and len(number) > 2:
return "+" + number[2:]
if number.isdigit() and number.startswith("98") and len(number) >= 12:
return "+" + number
if number.isdigit() and number.startswith("0") and len(number) > 1:
return "+98" + number[1:]
if number.isdigit() and len(number) == 10:
return "+98" + number
return number
def _phone_variants(value):
canonical = _canonical_number(value)
if not canonical:
return ()
variants = {canonical, _dialable_number(value)}
if canonical.startswith("+98"):
variants.update({"0" + canonical[3:], "00" + canonical[1:], canonical[1:]})
elif canonical.startswith("+"):
variants.add("00" + canonical[1:])
return variants
def _display_number(value):
canonical = _canonical_number(value)
return "0" + canonical[3:] if canonical.startswith("+98") else canonical
def _json(data, status=200):
response = JsonResponse(data, status=status, json_dumps_params={"ensure_ascii": False})
response["Cache-Control"] = "no-store"
return response
def _matching_contacts(contacts, query):
phone_query = query.translate(_PHONE_DIGITS)
if re.fullmatch(r"[+*#0-9().\s-]+", phone_query):
variants = _phone_variants(phone_query)
return contacts.filter(user__mobile__in=variants).order_by("pk") if variants else contacts.none()
for word in re.split(r"\s+", query):
contacts = contacts.filter(
Q(first_name__icontains=word) | Q(last_name__icontains=word) |
Q(organization_name__icontains=word) | Q(user__first_name__icontains=word) |
Q(user__last_name__icontains=word) | Q(user__mobile__icontains=word)
)
preferred = (
Q(first_name__iexact=query) | Q(last_name__iexact=query) |
Q(organization_name__iexact=query) | Q(user__first_name__iexact=query) |
Q(user__last_name__iexact=query)
)
starts_with = (
Q(first_name__istartswith=query) | Q(last_name__istartswith=query) |
Q(organization_name__istartswith=query) | Q(user__first_name__istartswith=query) |
Q(user__last_name__istartswith=query)
)
return contacts.annotate(_search_rank=Case(
When(preferred, then=Value(0)),
When(starts_with, then=Value(1)),
default=Value(2), output_field=IntegerField(),
)).order_by("_search_rank", "pk")
@require_GET
def phone_contacts(request):
authorization = request.headers.get("Authorization", "")
@@ -50,35 +112,13 @@ def phone_contacts(request):
Q(first_name__gt="") | Q(last_name__gt="") | Q(organization_name__gt="") |
Q(user__first_name__gt="") | Q(user__last_name__gt="")
)
for word in re.split(r"\s+", query):
contacts = contacts.filter(
Q(first_name__icontains=word) | Q(last_name__icontains=word) |
Q(organization_name__icontains=word) | Q(user__first_name__icontains=word) |
Q(user__last_name__icontains=word) | Q(user__mobile__icontains=word)
)
preferred = (
Q(first_name__iexact=query) | Q(last_name__iexact=query) |
Q(organization_name__iexact=query) | Q(user__first_name__iexact=query) |
Q(user__last_name__iexact=query)
)
starts_with = (
Q(first_name__istartswith=query) | Q(last_name__istartswith=query) |
Q(organization_name__istartswith=query) | Q(user__first_name__istartswith=query) |
Q(user__last_name__istartswith=query)
)
contacts = contacts.annotate(_search_rank=Case(
When(preferred, then=Value(0)),
When(starts_with, then=Value(1)),
default=Value(2), output_field=IntegerField(),
)).order_by("_search_rank", "pk")
results = []
for profile in contacts[:limit]:
for profile in _matching_contacts(contacts, query)[:limit]:
name = (f"{profile.first_name} {profile.last_name}".strip() or
f"{profile.user.first_name} {profile.user.last_name}".strip() or
profile.organization_name.strip())[:120]
number = _dialable_number(profile.user.local_mobile)
number = _display_number(profile.user.mobile)
if name and number:
results.append({"name": name, "phones": [{"type": "همراه", "number": number}]})
label = "همراه" if re.fullmatch(r"09[0-9]{9}", number) else "تلفن"
results.append({"name": name, "mobile": number, "phones": [{"type": label, "number": number}]})
return _json({"contacts": results})