use russian cities names for search params
This commit is contained in:
@@ -15,6 +15,7 @@ django-cors-headers = "*"
|
||||
requests = "*"
|
||||
pytz = "*"
|
||||
requests-pkcs12 = "*"
|
||||
cyrtranslit = "*"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
|
||||
10
backend/Pipfile.lock
generated
10
backend/Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "268a54d3447876143d9a71096db298c014bffccc4d8205ed357ead4e3bcd0a38"
|
||||
"sha256": "de2d16d83534b564a047f80e0d34616d4ecf2341972ddfe89f64fedecc33f842"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -246,6 +246,14 @@
|
||||
"markers": "python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'",
|
||||
"version": "==45.0.3"
|
||||
},
|
||||
"cyrtranslit": {
|
||||
"hashes": [
|
||||
"sha256:04edd4c89b1a4611b81de609f5c91f91afae980c728dea2f09522d7b10f73228",
|
||||
"sha256:51b65cb0497042231bce2951fd2df1fb8f659fc20ac355a1044e578b97870256"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"django": {
|
||||
"hashes": [
|
||||
"sha256:57fe1f1b59462caed092c80b3dd324fd92161b620d59a9ba9181c34746c97284",
|
||||
|
||||
@@ -22,14 +22,17 @@ class CountrySerializer(serializers.ModelSerializer):
|
||||
return obj.international_name or obj.official_name
|
||||
|
||||
class CitySerializer(serializers.ModelSerializer):
|
||||
value = serializers.CharField(source='name') # для совместимости с селектом
|
||||
label = serializers.CharField(source='name') # для отображения в селекте
|
||||
value = serializers.CharField(source='name') # для совместимости с селектом на фронте (используем английское название)
|
||||
label = serializers.SerializerMethodField() # для отображения в селекте (используем русское название)
|
||||
country_name = serializers.CharField(source='country.international_name')
|
||||
|
||||
class Meta:
|
||||
model = City
|
||||
fields = ['id', 'value', 'label', 'country_name']
|
||||
|
||||
def get_label(self, obj):
|
||||
return obj.russian_name or obj.name # используем русское название если есть, иначе английское
|
||||
|
||||
class RouteSerializer(serializers.ModelSerializer):
|
||||
from_city_name = serializers.SerializerMethodField()
|
||||
to_city_name = serializers.SerializerMethodField()
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.shortcuts import get_object_or_404
|
||||
from django.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from api.auth.serializers import UserResponseSerializer
|
||||
from api.models import UserProfile
|
||||
@@ -133,7 +134,10 @@ class CityView(ViewSet):
|
||||
# поиск по названию города
|
||||
search = request.query_params.get('search')
|
||||
if search:
|
||||
cities = cities.filter(name__icontains=search)
|
||||
cities = cities.filter(
|
||||
Q(name__icontains=search) | # поиск по английскому названию
|
||||
Q(russian_name__icontains=search) # поиск по русскому названию
|
||||
)
|
||||
|
||||
# ограничиваем количество результатов и сортируем по имени
|
||||
cities = cities.order_by('name')[:100]
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
from rest_framework import generics
|
||||
from typing import cast
|
||||
from rest_framework.request import Request
|
||||
from routes.models import Route
|
||||
from .serializers import SearchRouteSerializer
|
||||
from api.utils.pagination import StandardResultsSetPagination
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from routes.constants.routeChoices import owner_type_choices
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
import cyrtranslit
|
||||
from urllib.parse import unquote
|
||||
|
||||
def get_city_variations(city_name: str) -> list[str]:
|
||||
"""
|
||||
Получает варианты написания города, включая транслитерацию
|
||||
"""
|
||||
variations = set()
|
||||
|
||||
# добавляем оригинальное название и его варианты с разным регистром
|
||||
variations.add(city_name)
|
||||
variations.add(city_name.lower())
|
||||
variations.add(city_name.title())
|
||||
|
||||
# пробуем добавить транслитерации
|
||||
try:
|
||||
# пробуем транслитерировать в обе стороны
|
||||
lat = cyrtranslit.to_latin(city_name, 'ru')
|
||||
cyr = cyrtranslit.to_cyrillic(city_name, 'ru')
|
||||
|
||||
# добавляем варианты транслитерации с разным регистром
|
||||
for variant in [lat, cyr]:
|
||||
variations.add(variant)
|
||||
variations.add(variant.lower())
|
||||
variations.add(variant.title())
|
||||
except:
|
||||
pass
|
||||
|
||||
return list(variations)
|
||||
|
||||
class SearchRouteListView(generics.ListAPIView):
|
||||
serializer_class = SearchRouteSerializer
|
||||
@@ -14,8 +42,45 @@ class SearchRouteListView(generics.ListAPIView):
|
||||
|
||||
def get_queryset(self):
|
||||
owner_type = self.kwargs.get('owner_type')
|
||||
from_city = cast(Request, self.request).query_params.get('from')
|
||||
to_city = cast(Request, self.request).query_params.get('to')
|
||||
|
||||
# получаем маршрут из URL и разбиваем его на города
|
||||
route = self.kwargs.get('route', '')
|
||||
print(f"Raw route from URL: {route}")
|
||||
|
||||
if route:
|
||||
# декодируем URL дважды, так как он может быть дважды закодирован
|
||||
route = unquote(unquote(route))
|
||||
print(f"Decoded route: {route}")
|
||||
try:
|
||||
from_city, to_city = route.split('-', 1)
|
||||
print(f"Split cities - from: {from_city}, to: {to_city}")
|
||||
except ValueError:
|
||||
from_city = to_city = None
|
||||
else:
|
||||
# если маршрут не указан в URL, берем из query params и декодируем
|
||||
from_city = self.request.query_params.get('from', '')
|
||||
to_city = self.request.query_params.get('to', '')
|
||||
|
||||
# декодируем значения, если они закодированы
|
||||
if from_city:
|
||||
try:
|
||||
# пробуем декодировать дважды для случая двойного кодирования
|
||||
from_city = unquote(from_city)
|
||||
if '%' in from_city: # если после первого декодирования остались %-коды
|
||||
from_city = unquote(from_city)
|
||||
except Exception as e:
|
||||
print(f"Error decoding from_city: {e}")
|
||||
|
||||
if to_city:
|
||||
try:
|
||||
# пробуем декодировать дважды для случая двойного кодирования
|
||||
to_city = unquote(to_city)
|
||||
if '%' in to_city: # если после первого декодирования остались %-коды
|
||||
to_city = unquote(to_city)
|
||||
except Exception as e:
|
||||
print(f"Error decoding to_city: {e}")
|
||||
|
||||
print(f"Query params - from: {from_city}, to: {to_city}")
|
||||
|
||||
valid_types = [choice[0] for choice in owner_type_choices]
|
||||
current_time = timezone.now()
|
||||
@@ -24,15 +89,35 @@ class SearchRouteListView(generics.ListAPIView):
|
||||
raise ValidationError("Invalid or missing owner_type. Must be either 'customer' or 'mover'")
|
||||
|
||||
# базовый фильтр по типу владельца и актуальности
|
||||
queryset = Route.objects.filter(
|
||||
owner_type=owner_type,
|
||||
status="actual")
|
||||
queryset = Route.objects.all()
|
||||
|
||||
# Применяем базовые фильтры
|
||||
queryset = queryset.filter(owner_type=owner_type, status='actual')
|
||||
|
||||
# фильтруем по городам если они указаны
|
||||
if from_city:
|
||||
queryset = queryset.filter(from_city__name__iexact=from_city)
|
||||
print(f"Searching for from_city: {from_city}")
|
||||
# Получаем варианты написания для поиска
|
||||
from_city_variations = get_city_variations(from_city)
|
||||
print(f"From city variations: {from_city_variations}")
|
||||
# Используем Q objects для поиска по обоим полям
|
||||
from_city_filter = (
|
||||
Q(from_city__name__in=from_city_variations) |
|
||||
Q(from_city__russian_name__in=from_city_variations)
|
||||
)
|
||||
queryset = queryset.filter(from_city_filter)
|
||||
|
||||
if to_city:
|
||||
queryset = queryset.filter(to_city__name__iexact=to_city)
|
||||
print(f"Searching for to_city: {to_city}")
|
||||
# получаем варианты написания для поиска
|
||||
to_city_variations = get_city_variations(to_city)
|
||||
print(f"To city variations: {to_city_variations}")
|
||||
# используем Q objects для поиска по обоим полям
|
||||
to_city_filter = (
|
||||
Q(to_city__name__in=to_city_variations) |
|
||||
Q(to_city__russian_name__in=to_city_variations)
|
||||
)
|
||||
queryset = queryset.filter(to_city_filter)
|
||||
|
||||
# фильтруем по времени в зависимости от типа
|
||||
if owner_type == 'mover':
|
||||
|
||||
@@ -3,6 +3,7 @@ certifi==2025.4.26
|
||||
cffi==1.17.1
|
||||
charset-normalizer==3.4.2
|
||||
cryptography==45.0.3
|
||||
cyrtranslit==1.1.1
|
||||
Django==5.2.1
|
||||
django-cors-headers==4.7.0
|
||||
django-stubs==5.2.0
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.1 on 2025-05-27 07:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('routes', '0007_route_created_at_route_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='city',
|
||||
name='russian_name',
|
||||
field=models.CharField(blank=True, max_length=30, null=True, verbose_name='Название на русском'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='city',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=30, null=True, verbose_name='Название на английском'),
|
||||
),
|
||||
]
|
||||
@@ -46,7 +46,9 @@ class City(models.Model):
|
||||
country = models.ForeignKey(
|
||||
Country, verbose_name=('Страна'), related_name='rel_cities_for_country', on_delete=models.CASCADE)
|
||||
|
||||
name = models.CharField(max_length=30, verbose_name=('Название города'), blank=True, null=True)
|
||||
name = models.CharField(max_length=30, verbose_name=('Название на английском'), blank=True, null=True)
|
||||
russian_name = models.CharField(max_length=30, verbose_name=('Название на русском'), blank=True, null=True)
|
||||
|
||||
geo_lat = models.CharField(max_length=20, verbose_name=('GPS широта'), blank=True, null=True)
|
||||
geo_lon = models.CharField(max_length=20, verbose_name=('GPS долгота'), blank=True, null=True)
|
||||
|
||||
@@ -57,7 +59,7 @@ class City(models.Model):
|
||||
parsing_finished_DT = models.DateTimeField(verbose_name=('Дата и время завершения парсинга'), blank=True, null=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.name}'
|
||||
return f'{self.russian_name or self.name}'
|
||||
|
||||
class Meta:
|
||||
verbose_name = ('Город')
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface TextInputProps {
|
||||
isPassword?: boolean
|
||||
isVisible?: boolean
|
||||
togglePasswordVisibility?: () => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ButtonProps {
|
||||
|
||||
@@ -2,28 +2,81 @@
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import TextInput from './ui/TextInput'
|
||||
import Link from 'next/link'
|
||||
import axios from 'axios'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function AddressSelector() {
|
||||
const router = useRouter()
|
||||
const [fromAddress, setFromAddress] = useState('')
|
||||
const [toAddress, setToAddress] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL
|
||||
|
||||
const getCityName = async (searchText: string): Promise<string> => {
|
||||
try {
|
||||
const encodedSearch = encodeURIComponent(searchText)
|
||||
const url = `${API_URL}/cities/?search=${encodedSearch}&russian_name=${encodedSearch}`
|
||||
const response = await axios.get(url)
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
return response.data[0].value
|
||||
}
|
||||
|
||||
throw new Error(`Город "${searchText}" не найден`)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Ошибка при поиске города "${searchText}": ${error.message}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const formatAddress = (address: string) => {
|
||||
return address
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-zа-я0-9\s-]/gi, '')
|
||||
.replace(/[^a-zа-яё0-9\s-]/gi, '')
|
||||
.replace(/\s+/g, '-')
|
||||
}
|
||||
|
||||
const getSearchUrl = (category: 'mover' | 'customer') => {
|
||||
if (!fromAddress || !toAddress) return `/search/${category}`
|
||||
const validateInputs = () => {
|
||||
if (!fromAddress.trim()) {
|
||||
throw new Error('Укажите город отправления')
|
||||
}
|
||||
if (!toAddress.trim()) {
|
||||
throw new Error('Укажите город назначения')
|
||||
}
|
||||
if (fromAddress.trim() === toAddress.trim()) {
|
||||
throw new Error('Города отправления и назначения должны различаться')
|
||||
}
|
||||
}
|
||||
|
||||
const getSearchUrl = async (category: 'mover' | 'customer') => {
|
||||
validateInputs()
|
||||
|
||||
const [fromCity, toCity] = await Promise.all([getCityName(fromAddress), getCityName(toAddress)])
|
||||
|
||||
const from = formatAddress(fromCity)
|
||||
const to = formatAddress(toCity)
|
||||
|
||||
const from = formatAddress(fromAddress)
|
||||
const to = formatAddress(toAddress)
|
||||
return `/search/${category}/${from}-${to}`
|
||||
}
|
||||
|
||||
const handleSearch = async (category: 'mover' | 'customer') => {
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const url = await getSearchUrl(category)
|
||||
router.push(url)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Произошла ошибка при поиске')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2 w-full rounded-xl bg-white p-4 shadow-lg sm:my-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:gap-3">
|
||||
@@ -33,9 +86,13 @@ export default function AddressSelector() {
|
||||
tooltip="Укажите пункт (Город/Страна), откуда необходимо забрать посылку."
|
||||
label="Забрать посылку из"
|
||||
value={fromAddress}
|
||||
handleChange={e => setFromAddress(e.target.value)}
|
||||
handleChange={e => {
|
||||
setError(null)
|
||||
setFromAddress(e.target.value)
|
||||
}}
|
||||
name="fromAddress"
|
||||
style="main"
|
||||
error={error && !fromAddress.trim() ? 'Укажите город отправления' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full min-w-0 sm:flex-[3] sm:px-1">
|
||||
@@ -44,24 +101,37 @@ export default function AddressSelector() {
|
||||
label="Доставить посылку в"
|
||||
tooltip="Укажите пункт (Город/Страна), куда необходимо доставить посылку."
|
||||
value={toAddress}
|
||||
handleChange={e => setToAddress(e.target.value)}
|
||||
handleChange={e => {
|
||||
setError(null)
|
||||
setToAddress(e.target.value)
|
||||
}}
|
||||
name="toAddress"
|
||||
style="main"
|
||||
error={error && !toAddress.trim() ? 'Укажите город назначения' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href={getSearchUrl('mover')}
|
||||
className="bg-orange hover:bg-orange/80 w-full rounded-2xl p-4 text-center whitespace-nowrap text-white sm:w-auto sm:flex-1"
|
||||
<button
|
||||
onClick={() => handleSearch('mover')}
|
||||
disabled={isLoading}
|
||||
className={`w-full rounded-2xl p-4 text-center whitespace-nowrap text-white sm:w-auto sm:flex-1 ${
|
||||
isLoading ? 'bg-orange/50 cursor-not-allowed' : 'bg-orange hover:bg-orange/80'
|
||||
}`}
|
||||
>
|
||||
Найти перевозчика
|
||||
</Link>
|
||||
<Link
|
||||
href={getSearchUrl('customer')}
|
||||
className="w-full rounded-2xl bg-gray-100 p-4 text-center whitespace-nowrap text-gray-800 hover:bg-gray-200 sm:w-auto sm:flex-1"
|
||||
{isLoading ? 'Поиск...' : 'Найти перевозчика'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSearch('customer')}
|
||||
disabled={isLoading}
|
||||
className={`w-full rounded-2xl p-4 text-center whitespace-nowrap sm:w-auto sm:flex-1 ${
|
||||
isLoading
|
||||
? 'cursor-not-allowed bg-gray-100 text-gray-400'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Найти посылку
|
||||
</Link>
|
||||
{isLoading ? 'Поиск...' : 'Найти посылку'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="mt-4 text-center text-sm text-red-500">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const TextInput = ({
|
||||
isPassword,
|
||||
togglePasswordVisibility,
|
||||
isVisible,
|
||||
error,
|
||||
}: TextInputProps) => {
|
||||
const getStylesProps = () => {
|
||||
const baseStyles = 'px-3 py-2 '
|
||||
@@ -33,8 +34,8 @@ const TextInput = ({
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<div className="flex items-center gap-2 my-2">
|
||||
<label className="font-medium text-sm text-gray-500" htmlFor={name}>
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-500" htmlFor={name}>
|
||||
{label}
|
||||
</label>
|
||||
{tooltip && <Tooltip content={tooltip} />}
|
||||
@@ -48,7 +49,11 @@ const TextInput = ({
|
||||
placeholder={placeholder}
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
className={`${getStylesProps()} w-full border border-gray-300 text-black rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-400 focus:bg-white`}
|
||||
className={`${getStylesProps()} w-full border ${
|
||||
error ? 'border-red-500' : 'border-gray-300'
|
||||
} rounded-xl text-black focus:ring-1 focus:outline-none ${
|
||||
error ? 'focus:ring-red-400' : 'focus:ring-blue-400'
|
||||
} focus:bg-white`}
|
||||
autoComplete={name}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
@@ -56,12 +61,13 @@ const TextInput = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePasswordVisibility}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{isVisible ? <HiOutlineEye /> : <HiOutlineEyeOff />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="mt-1 text-sm text-red-500">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user