api contact
This commit is contained in:
1 parent
0b56766f96
commit
04d2bc957f
14 files changed
+595
-1
No files matched your search
@@ -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 باید در سمت مصرفکننده یا با محدود کردن دادههای این سرویس کنترل شود.
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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.
@@ -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"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import phone_contacts
|
||||
|
||||
|
||||
urlpatterns = [path("", phone_contacts, name="phone_contacts_api")]
|
||||
@@ -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})
|
||||
Reference in new issue
Block a user