Files
octopus/myproject/customers/views.py
Andrey Smakotin 81cadc8cf9 Добавлена умная фильтрация для поиска по номерам телефонов
Проблема: Поиск "x3m" неправильно находит клиента Наталью потому что её номер
содержит цифру "3". Это происходило, потому что система искала по любым цифрам
в query, даже если это явно не номер телефона.

Решение: Добавлена функция is_query_phone_only() которая проверяет, содержит ли
query ТОЛЬКО телефонные символы (цифры, +, -, (), пробелы, точка).

Поиск по номеру телефона происходит ТОЛЬКО если:
1. Query состоит ТОЛЬКО из телефонных символов (никаких букв)
2. И количество цифр >= 3

Примеры:
- "x3m" → НЕ ищет по цифре "3" (содержит букву)
- "29" → НЕ ищет по цифрам (только 2 цифры, нужно минимум 3)
- "295" → ИЩЕТ по цифрам "295" (только цифры, 3+ символов)
- "+375291234567" → ИЩЕТ по номеру (только телефонные символы)
- "team_x3m" → НЕ ищет по цифрам (содержит буквы и _)

Изменения:
1. Добавлена функция is_query_phone_only() в views.py
2. Обновлена api_search_customers() для использования новой функции
3. Обновлена customer_list() для использования новой функции
4. Добавлены 19 unit-тестов для is_query_phone_only()

Результаты тестирования:
✓ 42 теста всего (23 для determine_search_strategy + 19 для is_query_phone_only)
✓ Все тесты проходят успешно

Критические тест-кейсы:
✓ is_query_phone_only('x3m') == False (решает исходную проблему)
✓ is_query_phone_only('295') == True
✓ is_query_phone_only('+375291234567') == True

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 01:00:21 +03:00

445 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from django.core.paginator import Paginator
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
import phonenumbers
import json
from .models import Customer, Address
from .forms import CustomerForm
def normalize_query_phone(q):
"""Normalize phone number for search"""
try:
parsed = phonenumbers.parse(q, "BY")
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
return q
except:
return q
def customer_list(request):
"""Список всех клиентов"""
query = request.GET.get('q')
customers = Customer.objects.all()
if query:
# Используем ту же логику поиска, что и в AJAX API (api_search_customers)
# Это обеспечивает согласованность между веб-интерфейсом и API
# Нормализуем номер телефона
phone_normalized = normalize_query_phone(query)
# Определяем стратегию поиска
strategy, search_value = determine_search_strategy(query)
# Строим Q-объект для поиска (единая функция)
q_objects = build_customer_search_query(query, strategy, search_value)
# Добавляем поиск по телефону (умная логика)
if phone_normalized:
q_objects |= Q(phone__icontains=phone_normalized)
# Проверяем, похож ли query на номер телефона (только цифры и минимум 3 цифры)
query_digits = ''.join(c for c in query if c.isdigit())
should_search_by_phone_digits = is_query_phone_only(query) and len(query_digits) >= 3
if should_search_by_phone_digits:
# Ищем клиентов, чьи телефоны содержат введенные цифры
customers_by_phone = Customer.objects.filter(phone__isnull=False)
matching_by_digits = []
for customer in customers_by_phone:
customer_digits = ''.join(c for c in str(customer.phone) if c.isdigit())
if query_digits in customer_digits:
matching_by_digits.append(customer.pk)
if matching_by_digits:
q_objects |= Q(pk__in=matching_by_digits)
customers = customers.filter(q_objects)
customers = customers.order_by('-created_at')
# Пагинация
paginator = Paginator(customers, 25) # 25 клиентов на страницу
page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'query': query,
}
return render(request, 'customers/customer_list.html', context)
def customer_detail(request, pk):
"""Детали клиента"""
customer = get_object_or_404(Customer, pk=pk)
addresses = customer.addresses.all()
context = {
'customer': customer,
'addresses': addresses,
}
return render(request, 'customers/customer_detail.html', context)
def customer_create(request):
"""Создание нового клиента"""
if request.method == 'POST':
form = CustomerForm(request.POST)
if form.is_valid():
customer = form.save()
messages.success(request, f'Клиент {customer.full_name} успешно создан.')
return redirect('customers:customer-detail', pk=customer.pk)
else:
form = CustomerForm()
return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': True})
def customer_update(request, pk):
"""Редактирование клиента"""
customer = get_object_or_404(Customer, pk=pk)
if request.method == 'POST':
form = CustomerForm(request.POST, instance=customer)
if form.is_valid():
form.save()
messages.success(request, f'Клиент {customer.full_name} успешно обновлён.')
return redirect('customers:customer-detail', pk=customer.pk)
else:
form = CustomerForm(instance=customer)
return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': False})
def customer_delete(request, pk):
"""Удаление клиента"""
customer = get_object_or_404(Customer, pk=pk)
if request.method == 'POST':
customer_name = customer.full_name
customer.delete()
messages.success(request, f'Клиент {customer_name} успешно удален.')
return redirect('customers:customer-list')
context = {
'customer': customer
}
return render(request, 'customers/customer_confirm_delete.html', context)
# === AJAX API ENDPOINTS ===
def determine_search_strategy(query):
"""
Определяет стратегию поиска на основе содержимого query.
На основе наличия и позиции символа @ определяет, по каким полям и как искать.
Возвращает tuple (strategy, search_value):
- 'name_only': поиск только по имени (для очень коротких запросов)
- 'email_prefix': поиск по началу email (query заканчивается на @)
- 'email_domain': поиск по домену (query начинается с @)
- 'email_full': полный поиск по email (query содержит обе части)
- 'universal': поиск везде (имя + email, для средних запросов)
Примеры:
- 'team_x3m@' → ('email_prefix', 'team_x3m')
- '@bk' → ('email_domain', 'bk')
- 'test@bk.ru' → ('email_full', 'test@bk.ru')
- 'natul' → ('universal', 'natul')
- 'te' → ('name_only', 'te')
"""
if '@' in query:
local, _, domain = query.partition('@')
if not local:
# Начинается с @: ищем по домену
return ('email_domain', domain)
if not domain:
# Заканчивается на @: ищем по префиксу email
return ('email_prefix', local)
# Есть и локальная часть, и домен: полный поиск
return ('email_full', query)
else:
# Нет @: определяем по длине
if len(query) < 2:
return ('name_only', query)
if len(query) >= 3:
return ('universal', query)
# 2 символа: только имя (слишком мало для поиска по email)
return ('name_only', query)
def is_query_phone_only(query):
"""
Проверяет, содержит ли query только символы телефонного номера.
Возвращает True, если query состоит ТОЛЬКО из:
- цифр: 0-9
- телефонных символов: +, -, (, ), пробелов
Возвращает False, если есть буквы или другие символы (означает, что это поиск по имени/email).
Примеры:
- '295' → True (только цифры)
- '+375291234567' → True (цифры и телефонные символы)
- '(029) 123-45' → True (цифры и телефонные символы)
- 'x3m' → False (содержит буквы)
- 'team_x3m' → False (содержит буквы)
- 'Иван' → False (содержит буквы)
"""
if not query:
return False
# Проверяем, что query содержит ТОЛЬКО цифры и телефонные символы
phone_chars = set('0123456789+- ().')
return all(c in phone_chars for c in query)
def build_customer_search_query(query, strategy, search_value):
"""
Строит Q-объект для поиска клиентов на основе стратегии.
Используется в customer_list() и api_search_customers() для единообразия.
Args:
query: Исходный поисковый запрос (для fallback)
strategy: Стратегия поиска (из determine_search_strategy)
search_value: Значение для поиска (из determine_search_strategy)
Returns:
Q-объект для фильтрации Customer.objects
"""
if strategy == 'name_only':
return Q(name__icontains=search_value)
elif strategy == 'email_prefix':
# Query вида "team_x3m@" — ищем email, начинающиеся с "team_x3m"
return Q(email__istartswith=search_value)
elif strategy == 'email_domain':
# Query вида "@bk" — ищем все email с доменом "@bk.*"
return Q(email__icontains=f'@{search_value}')
elif strategy == 'email_full':
# Query вида "test@bk.ru" — полный поиск по email
return Q(email__icontains=search_value)
elif strategy == 'universal':
# Query вида "natul" (3+ символов) — ищем везде
return Q(name__icontains=search_value) | Q(email__icontains=search_value)
else:
# На случай неизвестной стратегии (не должно быть)
return Q(name__icontains=query)
@require_http_methods(["GET"])
def api_search_customers(request):
"""
AJAX endpoint для поиска клиента по имени, телефону или email.
Параметры GET:
- q: поисковая строка
Возвращает JSON с результатами поиска:
{
"results": [
{"id": 1, "text": "Иван Петров (+375291234567)", "name": "Иван Петров", "phone": "+375291234567", "email": "ivan@example.com"},
...
],
"pagination": {"more": false}
}
Если ничего не найдено и заданы параметры поиска, возвращает:
{
"results": [
{"id": null, "text": "Создать клиента: 'Поиск'", "is_create_option": true, "search_text": "Поиск"}
],
"pagination": {"more": false}
}
"""
query = request.GET.get('q', '').strip()
if not query or len(query) < 1:
return JsonResponse({
'results': [],
'pagination': {'more': False}
})
# Пытаемся нормализовать номер телефона для поиска
phone_normalized = normalize_query_phone(query)
# Для поиска по телефону: извлекаем только цифры и ищем по ним
# Это позволит найти клиента независимо от формата ввода
query_digits = ''.join(c for c in query if c.isdigit())
# Ищем по имени, email или телефону
# Используем Q-объекты для OR условий
# Определяем стратегию поиска на основе содержимого query
strategy, search_value = determine_search_strategy(query)
# Строим Q-объект для поиска (единая функция, используется везде)
q_objects = build_customer_search_query(query, strategy, search_value)
# Для поиска по номерам телефонов: применяем умную логику
# Ищем по телефону ТОЛЬКО если query состоит из ТОЛЬКО цифр и телефонных символов
# (никаких букв — потому что если есть буквы, это явно поиск по имени/email, не по телефону)
if phone_normalized:
q_objects |= Q(phone__icontains=phone_normalized)
# Проверяем, похож ли query на номер телефона (только цифры/+/-/() и минимум 3 цифры)
should_search_by_phone_digits = is_query_phone_only(query) and len(query_digits) >= 3
if should_search_by_phone_digits:
# Ищем клиентов, чьи телефоны содержат введенные цифры
customers_by_phone = Customer.objects.filter(phone__isnull=False)
matching_by_digits = []
for customer in customers_by_phone:
customer_digits = ''.join(c for c in str(customer.phone) if c.isdigit())
if query_digits in customer_digits:
matching_by_digits.append(customer.pk)
if matching_by_digits:
q_objects |= Q(pk__in=matching_by_digits)
customers = Customer.objects.filter(q_objects).distinct().order_by('name')[:20]
results = []
# Добавляем найденные клиентов
for customer in customers:
phone_display = str(customer.phone) if customer.phone else ''
text = customer.name
if phone_display:
text += f' ({phone_display})'
results.append({
'id': customer.pk,
'text': text,
'name': customer.name,
'phone': phone_display,
'email': customer.email,
})
# Если ничего не найдено, предлагаем создать нового клиента
if not results:
results.append({
'id': None,
'text': f'Создать клиента: "{query}"',
'is_create_option': True,
'search_text': query,
})
return JsonResponse({
'results': results,
'pagination': {'more': False}
})
@require_http_methods(["POST"])
def api_create_customer(request):
"""
AJAX endpoint для создания нового клиента.
Принимает POST JSON:
{
"name": "Иван Петров",
"phone": "+375291234567",
"email": "ivan@example.com"
}
Возвращает JSON:
{
"success": true,
"id": 123,
"name": "Иван Петров",
"phone": "+375291234567",
"email": "ivan@example.com"
}
При ошибке:
{
"success": false,
"error": "Клиент с таким номером телефона уже существует"
}
"""
try:
data = json.loads(request.body)
name = data.get('name', '').strip()
phone = data.get('phone', '').strip()
email = data.get('email', '').strip()
# Валидация: имя обязательно
if not name:
return JsonResponse({
'success': False,
'error': 'Имя клиента обязательно'
}, status=400)
# Нормализуем телефон если он указан
if phone:
phone = normalize_query_phone(phone)
# Проверяем, не существует ли уже клиент с таким телефоном
if phone and Customer.objects.filter(phone=phone).exists():
return JsonResponse({
'success': False,
'error': 'Клиент с таким номером телефона уже существует'
}, status=400)
# Проверяем, не существует ли уже клиент с таким email
if email and Customer.objects.filter(email=email).exists():
return JsonResponse({
'success': False,
'error': 'Клиент с таким email уже существует'
}, status=400)
# Создаем нового клиента
customer = Customer.objects.create(
name=name,
phone=phone if phone else None,
email=email if email else None
)
phone_display = str(customer.phone) if customer.phone else ''
return JsonResponse({
'success': True,
'id': customer.pk,
'name': customer.name,
'phone': phone_display,
'email': customer.email,
}, status=201)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Некорректный JSON'
}, status=400)
except ValidationError as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка сервера: {str(e)}'
}, status=500)