From 04d2bc957fd151f704ddea69f0a55277017235ca Mon Sep 17 00:00:00 2001 From: javad Date: Wed, 30 Sep 2026 08:39:26 +0330 Subject: [PATCH] api contact --- EXTERNAL_CONTACT_APIS.md | 179 ++++++++++++++++++ apps/phone_contacts_api/README.md | 11 ++ apps/phone_contacts_api/__init__.py | 0 apps/phone_contacts_api/admin.py | 44 +++++ apps/phone_contacts_api/apps.py | 7 + .../migrations/0001_initial.py | 20 ++ .../phone_contacts_api/migrations/__init__.py | 0 apps/phone_contacts_api/models.py | 25 +++ apps/phone_contacts_api/test_api.py | 80 ++++++++ apps/phone_contacts_api/urls.py | 6 + apps/phone_contacts_api/views.py | 84 ++++++++ config/settings/base.py | 2 +- config/urls.py | 1 + docs/PHONE_CONTACTS_API.md | 137 ++++++++++++++ 14 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 EXTERNAL_CONTACT_APIS.md create mode 100644 apps/phone_contacts_api/README.md create mode 100644 apps/phone_contacts_api/__init__.py create mode 100644 apps/phone_contacts_api/admin.py create mode 100644 apps/phone_contacts_api/apps.py create mode 100644 apps/phone_contacts_api/migrations/0001_initial.py create mode 100644 apps/phone_contacts_api/migrations/__init__.py create mode 100644 apps/phone_contacts_api/models.py create mode 100644 apps/phone_contacts_api/test_api.py create mode 100644 apps/phone_contacts_api/urls.py create mode 100644 apps/phone_contacts_api/views.py create mode 100644 docs/PHONE_CONTACTS_API.md diff --git a/EXTERNAL_CONTACT_APIS.md b/EXTERNAL_CONTACT_APIS.md new file mode 100644 index 0000000..813827b --- /dev/null +++ b/EXTERNAL_CONTACT_APIS.md @@ -0,0 +1,179 @@ +# اتصال مخاطبان CRM و سرویس‌های بیرونی به Phone + +این راهنما هم برای پیاده‌سازی endpoint جست‌وجو در CRM است و هم برای تنظیم آن در `/x5admin` ← «منابع بیرونی». Phone هنگام جست‌وجو از سرور خود به API درخواست می‌فرستد، پاسخ JSON را به نام و شماره‌های قابل تماس تبدیل می‌کند و نتیجه را در «مخاطبان» نشان می‌دهد. مخاطب بیرونی به دفترچهٔ محلی کپی نمی‌شود. + +## قرارداد پیشنهادی برای تیم CRM + +ساده‌ترین قرارداد، یک endpoint خواندنی با پاسخ JSON است: + +```http +GET /api/phone-contacts?search=%D8%B9%D9%84%DB%8C&limit=50 HTTP/1.1 +Host: crm.example.com +Authorization: Bearer +Accept: application/json +``` + +```json +{ + "contacts": [ + { + "name": "علی رضایی", + "phones": [ + {"type": "همراه", "number": "09123456789"}, + {"type": "دفتر", "number": "02112345678"} + ] + } + ] +} +``` + +CRM برای نبود نتیجه `{"contacts":[]}` با کد موفقیت `2xx` برگرداند. برای احراز هویت نامعتبر `401/403` و برای خطای سرویس کد `5xx` مناسب است. پاسخ باید JSON معتبر باشد؛ Phone متن یا XML را تفسیر نمی‌کند. CRM باید نتایج مرتبط را در **همان پاسخ اول** برگرداند، چون Phone صفحه‌بندی API بیرونی را دنبال نمی‌کند. + +| ویژگی پاسخ | رفتار Phone | +| --- | --- | +| نام | متن غیرخالی؛ فاصلهٔ ابتدا و انتها حذف و طول نمایش به ۱۲۰ نویسه محدود می‌شود. | +| شماره | به متن تبدیل می‌شود؛ فاصله، پرانتز، نقطه و خط تیره حذف و رقم‌های فارسی/عربی لاتین می‌شوند. حاصل باید فقط رقم، `+`، `*` یا `#` و حداکثر ۶۴ نویسه باشد. | +| نتیجهٔ بدون شمارهٔ معتبر | نمایش داده نمی‌شود. شماره‌های تکراری یک مخاطب فقط یک‌بار نمایش داده می‌شوند. | +| سقف پردازش | ۵۰ مورد نخست هر پاسخ، تا ۵ شماره برای هر مخاطب، حداکثر ۱ MiB برای کل پاسخ. | +| زمان پاسخ | مهلت هر منبع در پنل ۱ تا ۸ ثانیه است؛ مقدار پیش‌فرض ۵ ثانیه. | + +شناسهٔ مخاطب CRM برای این قابلیت لازم نیست. Phone شمارهٔ `0912...` را خودکار به `+98912...` تبدیل نمی‌کند؛ CRM باید شماره را در قالب قابل شماره‌گیری PBX بدهد. + +## نمونهٔ پیاده‌سازی endpoint در CRM + +قرارداد بالا به زبان یا فریم‌ورک خاصی وابسته نیست. منطق endpoint می‌تواند چنین باشد: + +```text +GET /api/phone-contacts?search=&limit=50 +1. Authorization: Bearer را بررسی کن. +2. متن search را trim و طول آن را محدود کن؛ برای متن کوتاه‌تر از ۲ نویسه نتیجهٔ خالی بده. +3. جست‌وجو را با query پارامتری در دیتابیس CRM اجرا کن؛ رکوردهایی را که نباید + برای کاربران Phone قابل مشاهده باشند، همین‌جا حذف کن. +4. نام و آرایهٔ شماره‌ها را در JSON قرارداد بالا برگردان؛ حداکثر ۵۰ نتیجه. +``` + +اگر در SQL از `LIKE` استفاده می‌کنید، مقدار ورودی را مستقیم وارد متن SQL نکنید؛ پارامتر bind کنید و اگر `%` و `_` نباید wildcard باشند، آن‌ها را مطابق موتور دیتابیس escape کنید. جست‌وجوی نام فارسی، شکل‌های متفاوت «ی/ي» و «ک/ك» و ترتیب نتایج را در CRM حل کنید؛ Phone فقط رشتهٔ واردشده را ارسال می‌کند. برای کارایی، ایندکس و حد نتیجه در CRM بگذارید. اگر API موجود ساختار دیگری دارد، به کمک نگاشت پنل آن را تطبیق دهید یا یک endpoint واسط JSON بسازید. + +## تنظیم قدم‌به‌قدم در x5admin + +1. در `/x5admin`، «منابع بیرونی» ← «افزودن منبع» را باز کنید. ابتدا گزینهٔ «فعال برای جست‌وجوی کاربران» را خاموش نگه دارید. +2. «نام منبع» یکتا و ۲ تا ۸۰ نویسه، مانند «CRM فروش»، تعیین کنید. این نام کنار نتایج دیده می‌شود. +3. روش `GET` یا `POST` و «آدرس ثابت API» را وارد کنید. آدرس باید کامل و به‌طور پیش‌فرض `https://` باشد؛ `{{query}}` را در خود آدرس نگذارید. +4. «پارامترهای URL»، «هدرها» و «بدنهٔ POST» را به شکل **شیء JSON معتبر** وارد کنید؛ مقدار خالی `{}` است. دست‌کم یک مقدار در پارامترها یا بدنه باید `{{query}}` داشته باشد. +5. برای `GET`، بدنه را `{}` بگذارید. برای `POST`، فرمت `JSON` یا `فرم` را مطابق CRM انتخاب کنید. پارامترهای URL در هر دو روش قابل استفاده‌اند. +6. «مسیر فهرست»، «مسیر نام مخاطب» و «نگاشت شماره‌ها» را از روی نمونهٔ **پاسخ واقعی** CRM تکمیل کنید. برای قرارداد ابتدای راهنما: `contacts`، `name` و `[{"path":"phones[]","value_path":"number","label_path":"type"}]`. +7. منبع را ذخیره کنید. برای آزمون واقعی، آن را فعال کنید و در `/phone` نام شناخته‌شده‌ای را جست‌وجو کنید. فعال‌سازی بلافاصله اعمال می‌شود و راه‌اندازی دوباره لازم ندارد. + +در نسخهٔ فعلی، پنل دکمهٔ «آزمایش درخواست» ندارد؛ ذخیرهٔ منبع غیرفعال، درخواستی به CRM نمی‌فرستد. برای بررسی مستقل، درخواست نمونه را با `curl` از **میزبان اجرای Phone** به CRM بفرستید. نمونه در بخش اعتبارنامه آمده است. + +### معنی فیلدهای درخواست + +| فیلد پنل | نمونه | توضیح | +| --- | --- | --- | +| آدرس ثابت API | `https://crm.example.com/api/phone-contacts` | مقصد ثابت؛ جای‌نگهدار جست‌وجو/رمز، نام کاربری یا رمز داخل URL و fragment پذیرفته نمی‌شود. | +| روش درخواست | `GET` / `POST` | سایر روش‌ها پشتیبانی نمی‌شوند. | +| پارامترهای URL | `{"search":"{{query}}","limit":50}` | به query string اضافه و توسط HTTP client کدگذاری می‌شوند. | +| هدرها | `{"Authorization":"Bearer {{env:PHONE_EXTERNAL_CRM_TOKEN}}"}` | هدرهای ثابت یا ارجاع به متغیر محیطی؛ `{{query}}` در هدر مجاز نیست. | +| بدنهٔ POST | `{"filter":{"name":"{{query}}"}}` | فقط برای POST؛ فرمت JSON یا فرم در فیلد بعدی تعیین می‌شود. | +| فرمت بدنه | `JSON` / `فرم` | JSON برای دادهٔ تو در تو؛ فرم برای فیلدهای سادهٔ `application/x-www-form-urlencoded`. | +| زمان انتظار | `5` | عدد ۱ تا ۸، از جمله مقدار اعشاری مانند `2.5`. | +| اجازهٔ HTTP | خاموش | فقط برای مقصد `http://` در شبکهٔ مورد اعتماد؛ ارتباط رمزگذاری نمی‌شود. | + +`{{query}}` در مقدارهای تو در توی پارامترها یا بدنه نیز جایگزین می‌شود. برای مثال `"%{{query}}%"` زمانی مفید است که خود CRM الگوی دارای `%` بخواهد. کلیدهای JSON جایگزین نمی‌شوند. Phone از امضای پویا، SOAP/XML، OAuth با refresh خودکار، GraphQL اختصاصی یا صفحه‌بندی خودکار پشتیبانی نمی‌کند؛ برای چنین قراردادهایی یک API واسط JSON بسازید. + +## نگاشت پاسخ JSON + +مسیر از نام کلیدهای JSON و نقطه تشکیل می‌شود؛ `[]` روی یک کلید، عضوهای آرایهٔ آن کلید را باز می‌کند. نام کلید در مسیر باید با حرف لاتین یا `_` شروع شود و سپس فقط حرف، رقم یا `_` داشته باشد. نحوهایی مانند `$.data`، `contacts[0]`، شرط‌های JSONPath و کلیدهای دارای خط تیره پشتیبانی نمی‌شوند. + +| پاسخ CRM | مسیر فهرست | مسیر نام | نگاشت شماره‌ها | +| --- | --- | --- | --- | +| `[{"name":"علی","mobile":"0912..."}]` | خالی | `name` | `[{"path":"mobile","label":"همراه"}]` | +| `{"data":{"contacts":[{"full_name":"علی","mobile":"0912..."}]}}` | `data.contacts` | `full_name` | `[{"path":"mobile","label":"همراه"}]` | +| `{"data":{"contacts":[{"name":"علی","phones":[{"type":"همراه","number":"0912..."}]}]}}` | `data.contacts` | `name` | `[{"path":"phones[]","value_path":"number","label_path":"type"}]` | +| `{"results":[{"person":{"name":"علی"},"mobile":"0912...","office":"021..."}]}` | `results` | `person.name` | `[{"path":"mobile","label":"همراه"},{"path":"office","label":"دفتر"}]` | + +«مسیر فهرست» نسبت به ریشهٔ پاسخ است. «مسیر نام» و هر `path` شماره نسبت به **هر عضو فهرست** هستند. `value_path` و `label_path` نسبت به مقدار یافته‌شده در همان `path` هستند. مثلاً در `phones[]` هر عضو یک شیء است؛ `value_path=number` شماره و `label_path=type` برچسب را می‌خواند. اگر `label_path` مقدار نداشته باشد، `label` ثابت استفاده می‌شود و در نبود آن برچسب «شماره» است. باید ۱ تا ۸ نگاشت شماره تعریف کنید. + +نمونهٔ کامل دیگر: + +```json +{ + "data": { + "people": [ + { + "profile": {"display_name": "مریم احمدی"}, + "phones": [ + {"kind": "همراه", "value": "+989121234567"}, + {"kind": "داخلی", "value": "102"} + ] + } + ] + } +} +``` + +تنظیم متناظر: مسیر فهرست `data.people`، مسیر نام `profile.display_name` و نگاشت `[{"path":"phones[]","value_path":"value","label_path":"kind"}]`. + +## نمونهٔ POST + +اگر CRM جست‌وجو را به صورت JSON می‌پذیرد، روش را `POST`، پارامترهای URL را `{}`، فرمت را `JSON` و بدنه را به شکل زیر بگذارید: + +```json +{"filter":{"name":"{{query}}"},"page_size":50} +``` + +این تنظیم برای جست‌وجوی «علی» بدنهٔ `{"filter":{"name":"علی"},"page_size":50}` می‌فرستد. اگر CRM فرم ساده می‌خواهد، فرمت `فرم` و بدنه را مثلاً `{"term":"{{query}}","limit":"50"}` بگذارید. برای بدنهٔ تو در تو از JSON استفاده کنید؛ نحوهٔ تفسیر فرم تو در تو به CRM بستگی دارد. + +## اعتبارنامه و هدرها + +رمز API را در `.env` پروژه یا محیط process اجرای Phone با پیشوند `PHONE_EXTERNAL_` قرار دهید: + +```dotenv +PHONE_EXTERNAL_CRM_TOKEN=replace-with-a-long-random-token +``` + +سپس در فیلد هدرها یکی از الگوهای زیر را وارد کنید: + +```json +{"Authorization":"Bearer {{env:PHONE_EXTERNAL_CRM_TOKEN}}","Accept":"application/json"} +``` + +```json +{"X-API-Key":"{{env:PHONE_EXTERNAL_CRM_TOKEN}}","Accept":"application/json"} +``` + +برای `Authorization`، `X-API-Key`، `Cookie` و `Proxy-Authorization` مقدار خام رمز پذیرفته نمی‌شود؛ ارجاع کامل به متغیر `PHONE_EXTERNAL_` لازم است. در `Authorization` پیشوند `Bearer ` یا `Basic ` نیز مجاز است. Phone ارجاع را هنگام هر درخواست، ابتدا از محیط process و سپس از `.env` کنار پروژه می‌خواند. تغییر متغیر محیط process نیازمند راه‌اندازی دوبارهٔ سرویس است؛ تغییر فایل `.env` برای درخواست بعدی خوانده می‌شود. توکن واقعی را در URL، نام منبع، تصویر پنل یا مستندات قرار ندهید. + +نمونهٔ بررسی از میزبان Phone، با توکنی که از قبل در محیط همان shell موجود است: + +```bash +curl --fail-with-body --get 'https://crm.example.com/api/phone-contacts' \ + --data-urlencode 'search=علی' \ + --data-urlencode 'limit=50' \ + -H "Authorization: Bearer ${PHONE_EXTERNAL_CRM_TOKEN}" \ + -H 'Accept: application/json' +``` + +اگر توکن در shell موجود نیست، آن را از مسیر امن سازمانی دریافت کنید. همچنین از میزبان Phone دسترسی DNS، TLS و شبکه به CRM را بررسی کنید. Phone تغییر مسیر HTTP را دنبال نمی‌کند و proxyهای محیطی را برای این درخواست‌ها به کار نمی‌گیرد. + +## دسترسی کاربران و جریان تماس + +1. کاربر وارد `/phone` می‌شود و در «مخاطبان» دست‌کم دو نویسه می‌نویسد. درخواست جست‌وجو حداکثر ۸۰ نویسه می‌پذیرد؛ Phone فاصلهٔ دو سر متن را حذف می‌کند و آن را به **همهٔ منابع فعال** می‌فرستد. +2. هر نتیجه نام، نام منبع و تا پنج گزینهٔ شماره دارد. اگر «مشاهدهٔ شمارهٔ مخاطبان» برای کاربر خاموش باشد، مقدار شماره در پاسخ Phone فرستاده نمی‌شود؛ گزینه‌هایی مثل «شمارهٔ ۱» همچنان قابل تماس‌اند. +3. Phone برای هر نتیجه شناسهٔ تصادفی موقت و وابسته به همان کاربر می‌سازد. این شناسه ۱۰ دقیقه معتبر است و شماره را در خود ندارد. هنگام تماس، مجوز «اجازهٔ تماس» و داشتن همراه یا داخلی تخصیص‌یافتهٔ کاربر بررسی می‌شود. +4. تماس از منطق click2call موجود انجام می‌شود. ویرایش، غیرفعال‌سازی یا حذف منبع، شناسه‌های تماس قبلی آن را باطل می‌کند؛ کاربر باید دوباره جست‌وجو کند. + +**مرز دسترسی CRM:** درخواست بیرونی با اعتبارنامهٔ خود منبع انجام می‌شود؛ در این نسخه شناسه، نقش یا توکن کاربر Phone به CRM فرستاده نمی‌شود. بنابراین هر نامی که با آن اعتبارنامه قابل جست‌وجو باشد، برای همهٔ کاربران واردشدهٔ Phone قابل مشاهده است. مجوز «مشاهدهٔ شمارهٔ مخاطبان» فقط شماره را در پاسخ Phone پنهان می‌کند؛ جلوی ارسال متن دلخواه جست‌وجو یا تطبیق احتمالی شماره توسط CRM را نمی‌گیرد. اگر CRM مخاطبان خصوصی یا سیاست‌های جداگانه برای کاربران دارد، endpoint یا اعتبارنامهٔ محدود مخصوص Phone بسازید و خروجی آن را در CRM محدود کنید. + +## محدودیت‌ها و عیب‌یابی + +| نشانه | بررسی | +| --- | --- | +| منبع ذخیره نمی‌شود و خطای `422` می‌آید | JSON هر فیلد معتبر باشد؛ `{{query}}` در پارامتر یا بدنه باشد؛ مسیر نام و نگاشت شماره تعریف شوند؛ برای GET بدنه `{}` باشد؛ آدرس HTTPS یا گزینهٔ اجازهٔ HTTP فعال باشد. | +| «موردی پیدا نشد» با وجود نتیجه در CRM | نمونهٔ پاسخ واقعی را ببینید؛ مسیر فهرست، نام و شماره را نسبت به محل درست تنظیم کنید. نام خالی یا همهٔ شماره‌های نامعتبر باعث حذف نتیجه می‌شوند. | +| «برخی منابع پاسخ ندادند» | اتصال DNS/TLS، کد HTTP، اعتبارنامه، زمان انتظار، JSON معتبر و سقف ۱ MiB را از سرور Phone بررسی کنید. خطای یک منبع، نتیجهٔ منابع سالم را حذف نمی‌کند. | +| پس از ویرایش منبع تماس قبلی برقرار نمی‌شود | شناسه‌های موقت با هر ویرایش باطل می‌شوند؛ دوباره جست‌وجو کنید. | +| شماره برای یک کاربر دیده نمی‌شود | مجوز «مشاهدهٔ شمارهٔ مخاطبان» همان کاربر را بررسی کنید. پنهان‌بودن شماره مانع تماس از دکمهٔ نتیجه نیست. | +| دکمهٔ تماس کار نمی‌کند | مجوز «اجازهٔ تماس»، همراه/داخلی تخصیص‌یافته، فعال‌بودن منبع و اعتبار ۱۰ دقیقه‌ای شناسه را بررسی کنید. | + +حداکثر ۲۰ منبع تعریف می‌شود و حداکثر چهار درخواست بیرونی هم‌زمان اجرا می‌شود. دادهٔ پاسخ در دفترچهٔ محلی ذخیره نمی‌شود؛ فقط شماره‌های لازم برای شناسه‌های تماس موقت در SQLite نگهداری می‌شوند. شناسه پس از ۱۰ دقیقه قابل استفاده نیست و ردیف منقضی‌شده هنگام ایجاد شناسه‌های بعدی پاک می‌شود. diff --git a/apps/phone_contacts_api/README.md b/apps/phone_contacts_api/README.md new file mode 100644 index 0000000..215d537 --- /dev/null +++ b/apps/phone_contacts_api/README.md @@ -0,0 +1,11 @@ +# خروجی مخاطبان برای Phone + +این برنامه endpoint خواندنی `GET /api/phone-contacts?search=...&limit=50` را مطابق قرارداد ابتدای `EXTERNAL_CONTACT_APIS.md` ارائه می‌کند. پاسخ همیشه شیء JSON با کلید `contacts` است. هر مخاطب فعال دارای پروفایل و نام، با شماره همراه در قالب محلی `09...` برگردانده می‌شود. جست‌وجو در نام، نام خانوادگی، نام سازمان و شماره ثبت‌شده انجام می‌شود. سقف پاسخ ۵۰ مخاطب است. + +## راه‌اندازی + +1. Migration برنامه را با محیط مجازی پروژه اجرا کنید: `.venv/Scripts/python.exe manage.py migrate phone_contacts_api`. +2. در `/x5admin/`، بخش «دسترسی API مخاطبان Phone» ← «کلید API مخاطبان Phone» را باز کنید. یک کلید تصادفی حداقل ۳۲ نویسه‌ای ثبت کنید. مقدار کلید فقط هنگام ذخیره دریافت می‌شود و در دیتابیس به صورت هش نگهداری می‌شود. برای تعویض کلید، رکورد را ویرایش کنید؛ خالی گذاشتن فیلد کلید، مقدار قبلی را حفظ می‌کند. با خاموش کردن «فعال»، دسترسی بلافاصله قطع می‌شود. +3. در پنل Phone منبع GET بسازید: آدرس ثابت `https:///api/phone-contacts`، پارامترها `{"search":"{{query}}","limit":50}`، هدر `{"Authorization":"Bearer {{env:PHONE_EXTERNAL_CRM_TOKEN}}"}`، مسیر فهرست `contacts`، مسیر نام `name` و نگاشت شماره‌ها `[{"path":"phones[]","value_path":"number","label_path":"type"}]`. مقدار `PHONE_EXTERNAL_CRM_TOKEN` در میزبان Phone باید همان کلید ثبت‌شده در دیتابیس این پروژه باشد. + +کلید به کاربران سایت وابسته نیست؛ خروجی این endpoint شامل همه مخاطبان فعال دارای پروفایل و نام است. همان‌طور که راهنمای Phone توضیح می‌دهد، دسترسی کاربران Phone باید در سمت مصرف‌کننده یا با محدود کردن داده‌های این سرویس کنترل شود. diff --git a/apps/phone_contacts_api/__init__.py b/apps/phone_contacts_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/phone_contacts_api/admin.py b/apps/phone_contacts_api/admin.py new file mode 100644 index 0000000..003950c --- /dev/null +++ b/apps/phone_contacts_api/admin.py @@ -0,0 +1,44 @@ +from django import forms +from django.contrib import admin + +from .models import PhoneContactApiKey + + +class PhoneContactApiKeyForm(forms.ModelForm): + new_key = forms.CharField( + label="کلید جدید", + required=False, + min_length=32, + max_length=256, + strip=False, + widget=forms.PasswordInput(render_value=False), + help_text="برای ایجاد، کلید تصادفی حداقل ۳۲ نویسه‌ای وارد کنید. هنگام ویرایش، خالی بگذارید تا کلید فعلی حفظ شود.", + ) + + class Meta: + model = PhoneContactApiKey + fields = ("is_active",) + + def clean_new_key(self): + value = self.cleaned_data["new_key"] + if not value and not self.instance.pk: + raise forms.ValidationError("برای ایجاد کلید، مقدار جدید لازم است.") + return value + + +@admin.register(PhoneContactApiKey) +class PhoneContactApiKeyAdmin(admin.ModelAdmin): + form = PhoneContactApiKeyForm + fields = ("new_key", "is_active", "updated_at") + readonly_fields = ("updated_at",) + + def has_add_permission(self, request): + return super().has_add_permission(request) and not PhoneContactApiKey.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + def save_model(self, request, obj, form, change): + if form.cleaned_data["new_key"]: + obj.set_key(form.cleaned_data["new_key"]) + super().save_model(request, obj, form, change) diff --git a/apps/phone_contacts_api/apps.py b/apps/phone_contacts_api/apps.py new file mode 100644 index 0000000..cfe2c98 --- /dev/null +++ b/apps/phone_contacts_api/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PhoneContactsApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.phone_contacts_api" + verbose_name = "دسترسی API مخاطبان Phone" diff --git a/apps/phone_contacts_api/migrations/0001_initial.py b/apps/phone_contacts_api/migrations/0001_initial.py new file mode 100644 index 0000000..a9237a7 --- /dev/null +++ b/apps/phone_contacts_api/migrations/0001_initial.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="PhoneContactApiKey", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("key_hash", models.CharField(editable=False, max_length=128)), + ("is_active", models.BooleanField(default=True, verbose_name="فعال")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="آخرین تغییر")), + ], + options={"verbose_name": "کلید API مخاطبان Phone", "verbose_name_plural": "کلید API مخاطبان Phone"}, + ), + ] diff --git a/apps/phone_contacts_api/migrations/__init__.py b/apps/phone_contacts_api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/phone_contacts_api/models.py b/apps/phone_contacts_api/models.py new file mode 100644 index 0000000..c2f24c7 --- /dev/null +++ b/apps/phone_contacts_api/models.py @@ -0,0 +1,25 @@ +from django.contrib.auth.hashers import check_password, make_password +from django.db import models + +from apps.core.models import SingletonConfiguration + + +class PhoneContactApiKey(SingletonConfiguration): + """One database-managed credential for the read-only Phone contact feed.""" + + key_hash = models.CharField(max_length=128, editable=False) + is_active = models.BooleanField(default=True, verbose_name="فعال") + updated_at = models.DateTimeField(auto_now=True, verbose_name="آخرین تغییر") + + class Meta: + verbose_name = "کلید API مخاطبان Phone" + verbose_name_plural = "کلید API مخاطبان Phone" + + def set_key(self, raw_key): + self.key_hash = make_password(raw_key) + + def accepts(self, raw_key): + return bool(self.is_active and self.key_hash and check_password(raw_key, self.key_hash)) + + def __str__(self): + return "کلید API مخاطبان Phone" diff --git a/apps/phone_contacts_api/test_api.py b/apps/phone_contacts_api/test_api.py new file mode 100644 index 0000000..491ef72 --- /dev/null +++ b/apps/phone_contacts_api/test_api.py @@ -0,0 +1,80 @@ +import pytest +from django.contrib.auth import get_user_model +from django.urls import reverse + +from apps.customers.models import CustomerProfile + +from .models import PhoneContactApiKey + + +pytestmark = pytest.mark.django_db +URL = "/api/phone-contacts" +FIRST_KEY = "first-phone-contact-api-key-1234567890" +SECOND_KEY = "second-phone-contact-api-key-1234567890" + + +def make_contact(*, mobile, first_name, last_name="", active=True): + user = get_user_model().objects.create_user( + username=f"contact_{mobile}", mobile=mobile, is_active=active, + account_type="CUSTOMER", + ) + return CustomerProfile.objects.create(user=user, first_name=first_name, last_name=last_name) + + +def authorize(client, key=FIRST_KEY): + return {"HTTP_AUTHORIZATION": f"Bearer {key}"} + + +def test_contact_feed_authentication_search_and_contract(client): + key = PhoneContactApiKey() + key.set_key(FIRST_KEY) + key.save() + make_contact(mobile="09123456789", first_name="علی", last_name="رضایی") + make_contact(mobile="09123456780", first_name="علی", last_name="غیرفعال", active=False) + make_contact(mobile="09123456781", first_name="مریم") + + assert client.get(URL, {"search": "علی"}).status_code == 401 + assert client.get(URL, {"search": "علی"}, **authorize(client, SECOND_KEY)).status_code == 401 + response = client.get(URL, {"search": " علی رضایی ", "limit": "50"}, **authorize(client)) + assert response.status_code == 200 + assert response.json() == {"contacts": [{ + "name": "علی رضایی", + "phones": [{"type": "همراه", "number": "09123456789"}], + }]} + assert response["Cache-Control"] == "no-store" + assert client.get(URL, {"search": "ع"}, **authorize(client)).json() == {"contacts": []} + assert client.get(URL, {"search": "هیچ"}, **authorize(client)).json() == {"contacts": []} + assert client.post(URL, **authorize(client)).status_code == 405 + + +def test_key_rotation_and_disabling_take_effect_immediately(client): + key = PhoneContactApiKey() + key.set_key(FIRST_KEY) + key.save() + assert client.get(URL, {"search": "علی"}, **authorize(client)).status_code == 200 + key.set_key(SECOND_KEY) + key.save() + assert client.get(URL, {"search": "علی"}, **authorize(client)).status_code == 401 + assert client.get(URL, {"search": "علی"}, **authorize(client, SECOND_KEY)).status_code == 200 + key.is_active = False + key.save() + assert client.get(URL, {"search": "علی"}, **authorize(client, SECOND_KEY)).status_code == 401 + + +def test_admin_can_create_and_rotate_key_without_exposing_hash(client): + admin = get_user_model().objects.create_superuser(username="api_admin", password="secure-admin-password") + client.force_login(admin) + add_url = reverse("admin:phone_contacts_api_phonecontactapikey_add") + response = client.post(add_url, {"new_key": FIRST_KEY, "is_active": "on", "_save": "Save"}) + assert response.status_code == 302 + key = PhoneContactApiKey.objects.get() + assert key.key_hash != FIRST_KEY + assert key.accepts(FIRST_KEY) + + change_url = reverse("admin:phone_contacts_api_phonecontactapikey_change", args=[key.pk]) + response = client.post(change_url, {"new_key": SECOND_KEY, "is_active": "on", "_save": "Save"}) + assert response.status_code == 302 + key.refresh_from_db() + assert not key.accepts(FIRST_KEY) + assert key.accepts(SECOND_KEY) + assert SECOND_KEY not in client.get(change_url).content.decode() diff --git a/apps/phone_contacts_api/urls.py b/apps/phone_contacts_api/urls.py new file mode 100644 index 0000000..23dbf4b --- /dev/null +++ b/apps/phone_contacts_api/urls.py @@ -0,0 +1,6 @@ +from django.urls import path + +from .views import phone_contacts + + +urlpatterns = [path("", phone_contacts, name="phone_contacts_api")] diff --git a/apps/phone_contacts_api/views.py b/apps/phone_contacts_api/views.py new file mode 100644 index 0000000..40de906 --- /dev/null +++ b/apps/phone_contacts_api/views.py @@ -0,0 +1,84 @@ +import re + +from django.db.models import Case, IntegerField, Q, Value, When +from django.http import JsonResponse +from django.views.decorators.http import require_GET + +from apps.customers.models import CustomerProfile + +from .models import PhoneContactApiKey + + +_PHONE_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789") + + +def _dialable_number(value): + number = re.sub(r"[\s().-]", "", str(value or "").translate(_PHONE_DIGITS)) + return number if 0 < len(number) <= 64 and re.fullmatch(r"[0-9+*#]+", number) else "" + + +def _json(data, status=200): + response = JsonResponse(data, status=status, json_dumps_params={"ensure_ascii": False}) + response["Cache-Control"] = "no-store" + return response + + +@require_GET +def phone_contacts(request): + authorization = request.headers.get("Authorization", "") + scheme, separator, token = authorization.partition(" ") + if not separator or scheme.lower() != "bearer" or not token or token != token.strip(): + return _json({"error": "unauthorized"}, 401) + + credential = PhoneContactApiKey.objects.filter(pk=1, is_active=True).first() + if not credential or not credential.accepts(token): + return _json({"error": "unauthorized"}, 401) + + query = request.GET.get("search", "").strip()[:80] + if len(query) < 2: + return _json({"contacts": []}) + + try: + limit = min(50, max(1, int(request.GET.get("limit", "50")))) + except ValueError: + return _json({"error": "invalid limit"}, 400) + + query = query.translate(str.maketrans({"ي": "ی", "ك": "ک", "۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4", "۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9"})) + contacts = CustomerProfile.objects.select_related("user").filter( + user__account_type="CUSTOMER", user__is_active=True, user__mobile__isnull=False + ).exclude(user__mobile="").filter( + 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]: + 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) + if name and number: + results.append({"name": name, "phones": [{"type": "همراه", "number": number}]}) + return _json({"contacts": results}) diff --git a/config/settings/base.py b/config/settings/base.py index 0aa6c35..50d8a3d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -23,7 +23,7 @@ INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "apps.core", "apps.accounts", "apps.customers", "apps.staff", "apps.tickets", - "apps.messaging", "apps.attachments", "apps.notifications", + "apps.messaging", "apps.attachments", "apps.notifications", "apps.phone_contacts_api", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", diff --git a/config/urls.py b/config/urls.py index 9d484cb..a00d645 100644 --- a/config/urls.py +++ b/config/urls.py @@ -58,6 +58,7 @@ urlpatterns = [ path("staff/", include("apps.tickets.staff_urls")), path("messages/", include("apps.messaging.urls")), path("files/", include("apps.attachments.urls")), + path("api/phone-contacts", include("apps.phone_contacts_api.urls")), ] if settings.DEBUG: diff --git a/docs/PHONE_CONTACTS_API.md b/docs/PHONE_CONTACTS_API.md new file mode 100644 index 0000000..bdee402 --- /dev/null +++ b/docs/PHONE_CONTACTS_API.md @@ -0,0 +1,137 @@ +# راهنمای API جست‌وجوی مخاطبان برای Phone + +این API مخاطبان ثبت‌شده در پروژهٔ تیکتینگ را به شکل قابل استفاده برای «منابع بیرونی» Phone ارائه می‌کند. قرارداد پاسخ آن با نمونهٔ ابتدایی `EXTERNAL_CONTACT_APIS.md` سازگار است. این endpoint فقط اطلاعات را می‌خواند؛ ایجاد یا ویرایش مخاطب از طریق آن انجام نمی‌شود. + +## نشانی و روش درخواست + +| مورد | مقدار | +| --- | --- | +| روش | `GET` | +| مسیر | `/api/phone-contacts`، بدون `/` پایانی | +| نوع پاسخ موفق | `application/json` با کد `200` | +| احراز هویت | هدر `Authorization: Bearer ` | +| بدنهٔ درخواست | ندارد؛ پارامترها در URL ارسال می‌شوند | + +نشانی کامل را با دامنهٔ همین پروژه بسازید؛ برای نمونه `https://crm.example.com/api/phone-contacts`. مقدار `` کلیدی است که مدیر در دیتابیس این پروژه ثبت کرده است. نشست کاربر یا کوکی برای این API لازم نیست. + +### پارامترهای query string + +| پارامتر | ضروری | رفتار | +| --- | --- | --- | +| `search` | برای دریافت نتیجه، بله | متن جست‌وجو؛ فاصله‌های ابتدا و انتها حذف می‌شوند و حداکثر ۸۰ نویسهٔ اول بررسی می‌شود. مقدار خالی، غایب یا کوتاه‌تر از دو نویسه، نتیجهٔ خالی می‌دهد. | +| `limit` | خیر | بیشترین تعداد مخاطب در پاسخ. پیش‌فرض `50` است. مقدار عددی کمتر از `1` به `1` و بیشتر از `50` به `50` محدود می‌شود؛ مقدار غیرعددی خطای `400` می‌دهد. | + +نمونهٔ درخواست HTTP: + +```http +GET /api/phone-contacts?search=%D8%B9%D9%84%DB%8C&limit=50 HTTP/1.1 +Host: crm.example.com +Authorization: Bearer +Accept: application/json +``` + +نمونه با `curl`، با فرض اینکه کلید از قبل در متغیر محیطی همان shell موجود است: + +```bash +curl --fail-with-body --get 'https://crm.example.com/api/phone-contacts' \ + --data-urlencode 'search=علی رضایی' \ + --data-urlencode 'limit=50' \ + -H "Authorization: Bearer ${PHONE_CONTACTS_API_KEY}" \ + -H 'Accept: application/json' +``` + +`--data-urlencode` متن فارسی و فاصله‌ها را برای URL کدگذاری می‌کند. کلید را در URL قرار ندهید. + +## پاسخ موفق + +ریشهٔ پاسخ همیشه یک شیء JSON با کلید `contacts` است. هر عضو این آرایه یک نام و آرایهٔ شماره‌ها دارد: + +```json +{ + "contacts": [ + { + "name": "علی رضایی", + "phones": [ + {"type": "همراه", "number": "09123456789"} + ] + } + ] +} +``` + +| فیلد | نوع | توضیح | +| --- | --- | --- | +| `contacts` | آرایه | حداکثر ۵۰ مخاطب، یا آرایهٔ خالی. | +| `contacts[].name` | رشته | نام نمایشی غیرخالی؛ فاصلهٔ دو سر حذف شده و حداکثر ۱۲۰ نویسه است. | +| `contacts[].phones` | آرایه | در ساختار کنونی پروژه، یک شمارهٔ همراه معتبر برای هر مخاطب. | +| `contacts[].phones[].type` | رشته | برای شمارهٔ موجود، مقدار `همراه`. | +| `contacts[].phones[].number` | رشته | شمارهٔ قابل شماره‌گیری، معمولاً در قالب محلی `09...`؛ تبدیل خودکار آن به `+98...` انجام نمی‌شود. | + +شماره پیش از ارسال به متن تبدیل می‌شود؛ فاصله، پرانتز، نقطه و خط تیره حذف می‌شوند و رقم‌های فارسی و عربی به رقم لاتین تبدیل می‌شوند. شمارهٔ نهایی باید حداکثر ۶۴ نویسه و فقط شامل رقم، `+`، `*` یا `#` باشد. مخاطب بدون نام یا شمارهٔ معتبر در پاسخ نمی‌آید. چون دیتابیس فعلی برای این خروجی شمارهٔ دفتر مستقلی ندارد، نمونهٔ بالا فقط شمارهٔ همراه را نشان می‌دهد؛ هیچ شمارهٔ فرضی تولید نمی‌شود. شناسهٔ داخلی مخاطب نیز در پاسخ فرستاده نمی‌شود. + +محدودیت ۵۰ مخاطب، نام ۱۲۰ نویسه‌ای و یک شمارهٔ حداکثر ۶۴ نویسه‌ای برای هر مخاطب، پاسخ را بسیار پایین‌تر از سقف پردازش ۱ MiB در Phone نگه می‌دارد. + +اگر مخاطبی پیدا نشود، درخواست همچنان موفق است: + +```http +HTTP/1.1 200 OK +Content-Type: application/json +Cache-Control: no-store + +{"contacts":[]} +``` + +برای پاسخ‌های JSON موفق و خطاهای JSON این view، هدر `Cache-Control: no-store` تنظیم می‌شود. + +## چه مخاطبانی نمایش داده می‌شوند؟ + +فقط رکوردهایی بررسی می‌شوند که پروفایل `CustomerProfile` داشته باشند، حساب مرتبط با آن‌ها از نوع `CUSTOMER` و فعال باشد، شمارهٔ همراه داشته باشد و دست‌کم یکی از فیلدهای نام، نام خانوادگی یا نام سازمان در پروفایل یا حساب آن‌ها پر باشد. سپس نام و شمارهٔ نهایی نیز اعتبارسنجی می‌شود. + +نام نمایشی به این ترتیب انتخاب می‌شود: نام و نام خانوادگی پروفایل، سپس نام و نام خانوادگی حساب، سپس نام سازمان پروفایل. خروجی نام به ۱۲۰ نویسه محدود می‌شود. + +جست‌وجو در نام، نام خانوادگی و نام سازمان پروفایل، نام و نام خانوادگی حساب، و شمارهٔ ذخیره‌شدهٔ حساب انجام می‌شود. برای جست‌وجوی چندواژه‌ای، **هر واژه** باید دست‌کم در یکی از این فیلدها پیدا شود. جست‌وجو نسبت به بزرگی حروف لاتین حساس نیست؛ شکل عربی «ي/ك» در متن ورودی به «ی/ک» تبدیل می‌شود. نتیجه‌هایی که یک فیلدشان دقیقاً با کل متن جست‌وجو برابر است، اول می‌آیند؛ سپس نتیجه‌هایی که فیلدی با آن متن شروع می‌شود و بعد سایر تطبیق‌ها. در هر گروه، شناسهٔ داخلی ترتیب را تعیین می‌کند. + +`limit` سقف تعداد رکوردهای بررسی‌شده برای خروجی را تعیین می‌کند. اگر در میان آن‌ها رکوردی هنگام ساخت پاسخ به علت نام یا شمارهٔ نامعتبر کنار گذاشته شود، ممکن است تعداد نهایی کمتر از `limit` باشد. API صفحه‌بندی یا پارامتر `page` ندارد؛ Phone باید نتایج مورد نیاز را از همین پاسخ اول دریافت کند. + +## کدهای پاسخ و خطاها + +| وضعیت | علت | بدنهٔ معمول | +| --- | --- | --- | +| `200` | درخواست معتبر؛ با نتیجه یا بدون نتیجه | `{"contacts":[...]}` یا `{"contacts":[]}` | +| `400` | مقدار `limit` عدد صحیح نیست، مانند `limit=abc` | `{"error":"invalid limit"}` | +| `401` | هدر Bearer غایب/نامعتبر است، کلید اشتباه است یا کلید در پنل غیرفعال شده است | `{"error":"unauthorized"}` | +| `405` | روشی غیر از `GET`، مانند `POST` | پاسخ استاندارد Django برای روش نامجاز | + +احراز هویت پیش از بررسی پارامترهای جست‌وجو انجام می‌شود؛ بنابراین درخواست بدون کلید حتی برای جست‌وجوی کوتاه نیز `401` می‌گیرد. خطاهای غیرمنتظرهٔ سرور ممکن است `5xx` باشند. + +## ایجاد و تغییر کلید API در دیتابیس + +1. Migration برنامه را با محیط مجازی همین مخزن اجرا کنید: `.venv/Scripts/python.exe manage.py migrate phone_contacts_api --noinput`. در محیط توسعهٔ محلی این migration قبلاً اعمال شده است؛ در هر محیط استقرار باید جداگانه اجرا شود. +2. با حساب دارای مجوز مدیریت وارد `/x5admin/` شوید و بخش «دسترسی API مخاطبان Phone» ← «کلید API مخاطبان Phone» را باز کنید. +3. هنگام ایجاد، یک کلید تصادفی با طول ۳۲ تا ۲۵۶ نویسه در فیلد «کلید جدید» وارد و گزینهٔ «فعال» را روشن کنید. در هر زمان فقط یک رکورد کلید قابل تعریف است. +4. برای تعویض کلید، همان رکورد را ویرایش و مقدار جدید وارد کنید. کلید قدیمی پس از ذخیره دیگر پذیرفته نمی‌شود. اگر فیلد را خالی بگذارید، کلید فعلی حفظ می‌شود. خاموش کردن «فعال» دسترسی را بلافاصله قطع می‌کند. + +مقدار خام کلید پس از ذخیره در پنل قابل بازیابی نیست و فقط هش آن در دیتابیس می‌ماند. کلیدی را که ثبت می‌کنید از مسیر امن به مسئول تنظیم Phone منتقل کنید. کلید در متغیر محیطی Phone نگهداری می‌شود، اما **منبع اعتبارسنجی این API دیتابیس همین پروژه است**؛ تغییر کلید در پنل پروژه باید با تغییر مقدار نزد Phone همراه باشد. + +## تنظیم منبع در Phone + +در پنل «منابع بیرونی» Phone، یک منبع با این مقادیر تعریف کنید: + +| فیلد پنل Phone | مقدار | +| --- | --- | +| آدرس ثابت API | `https://<دامنه-این-پروژه>/api/phone-contacts` | +| روش درخواست | `GET` | +| پارامترهای URL | `{"search":"{{query}}","limit":50}` | +| هدرها | `{"Authorization":"Bearer {{env:PHONE_EXTERNAL_CRM_TOKEN}}","Accept":"application/json"}` | +| بدنهٔ POST | `{}` | +| مسیر فهرست | `contacts` | +| مسیر نام مخاطب | `name` | +| نگاشت شماره‌ها | `[{"path":"phones[]","value_path":"number","label_path":"type"}]` | + +مقدار `PHONE_EXTERNAL_CRM_TOKEN` در محیط اجرای Phone باید با **کلید خامی** که در پنل همین پروژه ثبت شده برابر باشد. Phone از سرور خود به این API درخواست می‌فرستد؛ بنابراین DNS، TLS و دسترسی شبکه از **میزبان Phone** به دامنهٔ این پروژه باید برقرار باشد. دستور `curl` بالا را نیز ترجیحاً از همان میزبان اجرا کنید. + +## دامنهٔ دسترسی و محدودیت‌ها + +این API با یک کلید مشترک کار می‌کند و هویت کاربران Phone را دریافت نمی‌کند. در نتیجه هر دارندهٔ کلید می‌تواند مخاطبان واجد شرایط را جست‌وجو کند؛ مجوزهای دیدن شماره در رابط Phone، محدودسازی داده‌های خروجی این API نیستند. کلید را فقط در سامانهٔ مورد اعتماد قرار دهید و در صورت افشا فوراً آن را در پنل تغییر دهید. + +این endpoint فقط `GET` و احراز هویت Bearer را پیاده‌سازی می‌کند. شمارهٔ دفتر، چند شماره برای یک مخاطب، صفحه‌بندی، جست‌وجوی اختصاصی بر اساس واحد سازمانی و ایجاد/ویرایش مخاطب در این API وجود ندارد. برای قالب‌های دیگر پاسخ یا داده‌های بیشتر باید منبع دادهٔ متناظر در پروژه وجود داشته باشد.