From bc3ef3fb5720aa4dcac38ad5b61b7900c158a6a6 Mon Sep 17 00:00:00 2001 From: Timofey Date: Tue, 27 May 2025 11:51:51 +0300 Subject: [PATCH] use russian cities names for search params --- backend/Pipfile | 1 + backend/Pipfile.lock | 10 +- backend/api/account/client/serializers.py | 7 +- backend/api/account/client/views.py | 6 +- backend/api/search/views.py | 103 +++++++++++++++-- backend/requirements.txt | 1 + .../0008_city_russian_name_alter_city_name.py | 23 ++++ backend/routes/models.py | 6 +- frontend/app/types/index.ts | 1 + frontend/components/AddressSelector.tsx | 106 +++++++++++++++--- frontend/components/ui/TextInput.tsx | 14 ++- 11 files changed, 241 insertions(+), 37 deletions(-) create mode 100644 backend/routes/migrations/0008_city_russian_name_alter_city_name.py diff --git a/backend/Pipfile b/backend/Pipfile index 5e709fe..06ac7b2 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -15,6 +15,7 @@ django-cors-headers = "*" requests = "*" pytz = "*" requests-pkcs12 = "*" +cyrtranslit = "*" [dev-packages] diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index c0973a9..0336fc1 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -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", diff --git a/backend/api/account/client/serializers.py b/backend/api/account/client/serializers.py index e48641e..eb0bbf9 100644 --- a/backend/api/account/client/serializers.py +++ b/backend/api/account/client/serializers.py @@ -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() diff --git a/backend/api/account/client/views.py b/backend/api/account/client/views.py index 66d434f..6f2fe10 100644 --- a/backend/api/account/client/views.py +++ b/backend/api/account/client/views.py @@ -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] diff --git a/backend/api/search/views.py b/backend/api/search/views.py index e131045..8a15759 100644 --- a/backend/api/search/views.py +++ b/backend/api/search/views.py @@ -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': diff --git a/backend/requirements.txt b/backend/requirements.txt index e4010f3..814f0d2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routes/migrations/0008_city_russian_name_alter_city_name.py b/backend/routes/migrations/0008_city_russian_name_alter_city_name.py new file mode 100644 index 0000000..89f8f9b --- /dev/null +++ b/backend/routes/migrations/0008_city_russian_name_alter_city_name.py @@ -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='Название на английском'), + ), + ] diff --git a/backend/routes/models.py b/backend/routes/models.py index c47de4b..67b3143 100644 --- a/backend/routes/models.py +++ b/backend/routes/models.py @@ -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 = ('Город') diff --git a/frontend/app/types/index.ts b/frontend/app/types/index.ts index 04a543f..6cf564a 100644 --- a/frontend/app/types/index.ts +++ b/frontend/app/types/index.ts @@ -15,6 +15,7 @@ export interface TextInputProps { isPassword?: boolean isVisible?: boolean togglePasswordVisibility?: () => void + error?: string } export interface ButtonProps { diff --git a/frontend/components/AddressSelector.tsx b/frontend/components/AddressSelector.tsx index 9a9e04d..b5dd71f 100644 --- a/frontend/components/AddressSelector.tsx +++ b/frontend/components/AddressSelector.tsx @@ -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(null) + const API_URL = process.env.NEXT_PUBLIC_API_URL + + const getCityName = async (searchText: string): Promise => { + 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 (
@@ -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} />
@@ -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} />
- 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' + }`} > - Найти перевозчика - - +
+ {error &&
{error}
} ) } diff --git a/frontend/components/ui/TextInput.tsx b/frontend/components/ui/TextInput.tsx index 3963ce8..a44327d 100644 --- a/frontend/components/ui/TextInput.tsx +++ b/frontend/components/ui/TextInput.tsx @@ -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 (
{label && ( -
-
) }