api contact

This commit is contained in:
javad committed 2026-09-30 08:39:26 +03:30
1 parent 0b56766f96
commit 04d2bc957f
14 files changed
+595 -1

No files matched your search

+11
View File
@@ -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://<host>/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 باید در سمت مصرف‌کننده یا با محدود کردن داده‌های این سرویس کنترل شود.
View File
Whitespace-only changes.
+44
View File
@@ -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)
+7
View File
@@ -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"
@@ -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"},
),
]
Whitespace-only changes.
+25
View File
@@ -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"
+80
View File
@@ -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()
+6
View File
@@ -0,0 +1,6 @@
from django.urls import path
from .views import phone_contacts
urlpatterns = [path("", phone_contacts, name="phone_contacts_api")]
+84
View File
@@ -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})