fix api contact search
This commit is contained in:
1 parent
04d2bc957f
commit
39e64854b8
4 files changed
+124
-33
No files matched your search
@@ -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` آمده است.
|
||||
|
||||
## راهاندازی
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
Reference in new issue
Block a user