From 39e64854b87b4f51a45dd5aa238af642e1cbf476 Mon Sep 17 00:00:00 2001 From: javad Date: Wed, 30 Sep 2026 11:09:12 +0330 Subject: [PATCH] fix api contact search --- apps/phone_contacts_api/README.md | 2 +- apps/phone_contacts_api/test_api.py | 45 ++++++++++++++ apps/phone_contacts_api/views.py | 92 +++++++++++++++++++++-------- docs/PHONE_CONTACTS_API.md | 18 ++++-- 4 files changed, 124 insertions(+), 33 deletions(-) diff --git a/apps/phone_contacts_api/README.md b/apps/phone_contacts_api/README.md index 215d537..639e8ea 100644 --- a/apps/phone_contacts_api/README.md +++ b/apps/phone_contacts_api/README.md @@ -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` آمده است. ## راه‌اندازی diff --git a/apps/phone_contacts_api/test_api.py b/apps/phone_contacts_api/test_api.py index 491ef72..43070c2 100644 --- a/apps/phone_contacts_api/test_api.py +++ b/apps/phone_contacts_api/test_api.py @@ -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) diff --git a/apps/phone_contacts_api/views.py b/apps/phone_contacts_api/views.py index 40de906..967f5d7 100644 --- a/apps/phone_contacts_api/views.py +++ b/apps/phone_contacts_api/views.py @@ -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}) diff --git a/docs/PHONE_CONTACTS_API.md b/docs/PHONE_CONTACTS_API.md index bdee402..3bf9720 100644 --- a/docs/PHONE_CONTACTS_API.md +++ b/docs/PHONE_CONTACTS_API.md @@ -44,13 +44,14 @@ curl --fail-with-body --get 'https://crm.example.com/api/phone-contacts' \ ## پاسخ موفق -ریشهٔ پاسخ همیشه یک شیء JSON با کلید `contacts` است. هر عضو این آرایه یک نام و آرایهٔ شماره‌ها دارد: +ریشهٔ پاسخ همیشه یک شیء JSON با کلید `contacts` است. هر عضو این آرایه نام، شمارهٔ همراه و آرایهٔ شماره‌ها را دارد: ```json { "contacts": [ { "name": "علی رضایی", + "mobile": "09123456789", "phones": [ {"type": "همراه", "number": "09123456789"} ] @@ -63,11 +64,12 @@ curl --fail-with-body --get 'https://crm.example.com/api/phone-contacts' \ | --- | --- | --- | | `contacts` | آرایه | حداکثر ۵۰ مخاطب، یا آرایهٔ خالی. | | `contacts[].name` | رشته | نام نمایشی غیرخالی؛ فاصلهٔ دو سر حذف شده و حداکثر ۱۲۰ نویسه است. | -| `contacts[].phones` | آرایه | در ساختار کنونی پروژه، یک شمارهٔ همراه معتبر برای هر مخاطب. | -| `contacts[].phones[].type` | رشته | برای شمارهٔ موجود، مقدار `همراه`. | -| `contacts[].phones[].number` | رشته | شمارهٔ قابل شماره‌گیری، معمولاً در قالب محلی `09...`؛ تبدیل خودکار آن به `+98...` انجام نمی‌شود. | +| `contacts[].mobile` | رشته | همان مقدار `phones[].number`، برای مصرف‌کننده‌ای که فیلد مستقیم شماره را می‌خواهد. نام این فیلد برای سازگاری حفظ شده و ممکن است مقدار آن تلفن ثابت باشد. | +| `contacts[].phones` | آرایه | در ساختار کنونی پروژه، یک شمارهٔ ثبت‌شدهٔ معتبر برای هر مخاطب. | +| `contacts[].phones[].type` | رشته | برای همراه ایرانی `همراه` و برای شمارهٔ دیگر `تلفن`. | +| `contacts[].phones[].number` | رشته | شمارهٔ قابل شماره‌گیری؛ شمارهٔ ایرانی با `0` محلی برگردانده می‌شود، مانند `0913...` یا `021...`. | -شماره پیش از ارسال به متن تبدیل می‌شود؛ فاصله، پرانتز، نقطه و خط تیره حذف می‌شوند و رقم‌های فارسی و عربی به رقم لاتین تبدیل می‌شوند. شمارهٔ نهایی باید حداکثر ۶۴ نویسه و فقط شامل رقم، `+`، `*` یا `#` باشد. مخاطب بدون نام یا شمارهٔ معتبر در پاسخ نمی‌آید. چون دیتابیس فعلی برای این خروجی شمارهٔ دفتر مستقلی ندارد، نمونهٔ بالا فقط شمارهٔ همراه را نشان می‌دهد؛ هیچ شمارهٔ فرضی تولید نمی‌شود. شناسهٔ داخلی مخاطب نیز در پاسخ فرستاده نمی‌شود. +شماره پیش از ارسال به متن تبدیل می‌شود؛ فاصله، پرانتز، نقطه و خط تیره حذف می‌شوند و رقم‌های فارسی و عربی به رقم لاتین تبدیل می‌شوند. شمارهٔ نهایی باید حداکثر ۶۴ نویسه و فقط شامل رقم، `+`، `*` یا `#` باشد. مخاطب بدون نام یا شمارهٔ معتبر در پاسخ نمی‌آید. چون دیتابیس فعلی فیلد مستقل برای تلفن ثابت ندارد، هیچ شمارهٔ فرضی تولید نمی‌شود. شناسهٔ داخلی مخاطب نیز در پاسخ فرستاده نمی‌شود. محدودیت ۵۰ مخاطب، نام ۱۲۰ نویسه‌ای و یک شمارهٔ حداکثر ۶۴ نویسه‌ای برای هر مخاطب، پاسخ را بسیار پایین‌تر از سقف پردازش ۱ MiB در Phone نگه می‌دارد. @@ -89,7 +91,11 @@ Cache-Control: no-store نام نمایشی به این ترتیب انتخاب می‌شود: نام و نام خانوادگی پروفایل، سپس نام و نام خانوادگی حساب، سپس نام سازمان پروفایل. خروجی نام به ۱۲۰ نویسه محدود می‌شود. -جست‌وجو در نام، نام خانوادگی و نام سازمان پروفایل، نام و نام خانوادگی حساب، و شمارهٔ ذخیره‌شدهٔ حساب انجام می‌شود. برای جست‌وجوی چندواژه‌ای، **هر واژه** باید دست‌کم در یکی از این فیلدها پیدا شود. جست‌وجو نسبت به بزرگی حروف لاتین حساس نیست؛ شکل عربی «ي/ك» در متن ورودی به «ی/ک» تبدیل می‌شود. نتیجه‌هایی که یک فیلدشان دقیقاً با کل متن جست‌وجو برابر است، اول می‌آیند؛ سپس نتیجه‌هایی که فیلدی با آن متن شروع می‌شود و بعد سایر تطبیق‌ها. در هر گروه، شناسهٔ داخلی ترتیب را تعیین می‌کند. +اگر `search` شماره باشد، شکل‌های محلی با `0`، بین‌المللی با `+98` یا `0098`، و شکل ۱۰ رقمی بدون پیشوند برای شماره‌های ایرانی به یک قالب مقایسه تبدیل می‌شوند. این قاعده به `912` یا هیچ پیش‌شمارهٔ موبایلی محدود نیست؛ برای نمونه `02112345678`، `+982112345678` و `00982112345678` نیز معادل‌اند. رقم‌های فارسی/عربی و جداکننده‌هایی مانند فاصله و خط تیره پذیرفته می‌شوند. تطبیق با مقدار ذخیره‌شده **دقیق** است و شمارهٔ ایرانی در پاسخ با `0` محلی برمی‌گردد. + +این API فقط شماره‌ای را می‌تواند پیدا کند که در دادهٔ موجود حساب مخاطب ذخیره شده باشد. مدل فعلی `User.mobile` هنگام ثبت معمول، شمارهٔ همراه ایرانی را می‌پذیرد و فیلد جداگانه‌ای برای خط ثابت ندارد؛ بنابراین جست‌وجوی `021...` فقط برای داده‌ای نتیجه می‌دهد که چنین شماره‌ای از قبل در آن موجود باشد. مدل مخاطب و فرم ثبت آن برای این قابلیت تغییر نکرده‌اند. + +برای جست‌وجوی متنی، نام، نام خانوادگی و نام سازمان پروفایل و نام و نام خانوادگی حساب بررسی می‌شوند. برای جست‌وجوی چندواژه‌ای، **هر واژه** باید دست‌کم در یکی از این فیلدها پیدا شود. جست‌وجو نسبت به بزرگی حروف لاتین حساس نیست؛ شکل عربی «ي/ك» در متن ورودی به «ی/ک» تبدیل می‌شود. نتیجه‌هایی که یک فیلدشان دقیقاً با کل متن جست‌وجو برابر است، اول می‌آیند؛ سپس نتیجه‌هایی که فیلدی با آن متن شروع می‌شود و بعد سایر تطبیق‌ها. در هر گروه، شناسهٔ داخلی ترتیب را تعیین می‌کند. `limit` سقف تعداد رکوردهای بررسی‌شده برای خروجی را تعیین می‌کند. اگر در میان آن‌ها رکوردی هنگام ساخت پاسخ به علت نام یا شمارهٔ نامعتبر کنار گذاشته شود، ممکن است تعداد نهایی کمتر از `limit` باشد. API صفحه‌بندی یا پارامتر `page` ندارد؛ Phone باید نتایج مورد نیاز را از همین پاسخ اول دریافت کند.