- Add /translations/seed-all-defaults API endpoint - Loads all DEFAULT_TRANSLATIONS (makers, models, colors, fuels, transmissions) - Includes 100+ predefined translations (Mohave, Sonata, colors, etc.) - Add 'Seed Defaults' button to admin translations page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1054 lines
43 KiB
Python
1054 lines
43 KiB
Python
# Translation API with default dictionaries
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import or_
|
||
from typing import Optional, List
|
||
from ..database import get_db
|
||
from ..models import Translation
|
||
from ..schemas import (
|
||
TranslationCreate, TranslationUpdate, TranslationResponse,
|
||
TranslationListResponse, TranslationBulkRequest, TranslationBulkResponse,
|
||
)
|
||
|
||
router = APIRouter(prefix="/translations", tags=["translations"])
|
||
|
||
# Categories for translations
|
||
TRANSLATION_CATEGORIES = [
|
||
"maker", # Car makers (Hyundai, Kia, etc.)
|
||
"model", # Car models (Sonata, K5, etc.)
|
||
"fuel", # Fuel types (가솔린, 디젤, etc.)
|
||
"transmission", # Transmission types (자동, 수동, etc.)
|
||
"color", # Colors (흰색, 검정색, etc.)
|
||
"car_name", # Full car names
|
||
"general", # General terms
|
||
]
|
||
|
||
# Default translation dictionary for common Korean terms
|
||
DEFAULT_TRANSLATIONS = {
|
||
# Fuel types (연료)
|
||
"fuel": {
|
||
"가솔린": {"en": "Gasoline", "mn": "Бензин", "ru": "Бензин"},
|
||
"휘발유": {"en": "Gasoline", "mn": "Бензин", "ru": "Бензин"},
|
||
"디젤": {"en": "Diesel", "mn": "Дизель", "ru": "Дизель"},
|
||
"경유": {"en": "Diesel", "mn": "Дизель", "ru": "Дизель"},
|
||
"LPG": {"en": "LPG", "mn": "LPG", "ru": "Газ"},
|
||
"가스": {"en": "LPG", "mn": "LPG", "ru": "Газ"},
|
||
"전기": {"en": "Electric", "mn": "Цахилгаан", "ru": "Электро"},
|
||
"하이브리드": {"en": "Hybrid", "mn": "Хайбрид", "ru": "Гибрид"},
|
||
"플러그인하이브리드": {"en": "Plug-in Hybrid", "mn": "Плаг-ин Хайбрид", "ru": "Плагин гибрид"},
|
||
"수소": {"en": "Hydrogen", "mn": "Устөрөгч", "ru": "Водород"},
|
||
"CNG": {"en": "CNG", "mn": "CNG", "ru": "Метан"},
|
||
},
|
||
# Transmission types (변속기)
|
||
"transmission": {
|
||
"자동": {"en": "Automatic", "mn": "Автомат", "ru": "Автомат"},
|
||
"오토": {"en": "Automatic", "mn": "Автомат", "ru": "Автомат"},
|
||
"수동": {"en": "Manual", "mn": "Механик", "ru": "Механика"},
|
||
"스틱": {"en": "Manual", "mn": "Механик", "ru": "Механика"},
|
||
"CVT": {"en": "CVT", "mn": "CVT", "ru": "Вариатор"},
|
||
"무단변속기": {"en": "CVT", "mn": "CVT", "ru": "Вариатор"},
|
||
"DCT": {"en": "DCT", "mn": "DCT", "ru": "Робот"},
|
||
"듀얼클러치": {"en": "Dual Clutch", "mn": "Давхар холбоо", "ru": "Робот"},
|
||
"세미오토": {"en": "Semi-Auto", "mn": "Хагас автомат", "ru": "Полуавтомат"},
|
||
},
|
||
# Colors (색상)
|
||
"color": {
|
||
"흰색": {"en": "White", "mn": "Цагаан", "ru": "Белый"},
|
||
"화이트": {"en": "White", "mn": "Цагаан", "ru": "Белый"},
|
||
"백색": {"en": "White", "mn": "Цагаан", "ru": "Белый"},
|
||
"검정": {"en": "Black", "mn": "Хар", "ru": "Чёрный"},
|
||
"검정색": {"en": "Black", "mn": "Хар", "ru": "Чёрный"},
|
||
"블랙": {"en": "Black", "mn": "Хар", "ru": "Чёрный"},
|
||
"은색": {"en": "Silver", "mn": "Мөнгөлөг", "ru": "Серебристый"},
|
||
"실버": {"en": "Silver", "mn": "Мөнгөлөг", "ru": "Серебристый"},
|
||
"회색": {"en": "Gray", "mn": "Саарал", "ru": "Серый"},
|
||
"그레이": {"en": "Gray", "mn": "Саарал", "ru": "Серый"},
|
||
"진회색": {"en": "Dark Gray", "mn": "Бараан саарал", "ru": "Тёмно-серый"},
|
||
"빨강": {"en": "Red", "mn": "Улаан", "ru": "Красный"},
|
||
"빨간색": {"en": "Red", "mn": "Улаан", "ru": "Красный"},
|
||
"레드": {"en": "Red", "mn": "Улаан", "ru": "Красный"},
|
||
"파랑": {"en": "Blue", "mn": "Хөх", "ru": "Синий"},
|
||
"파란색": {"en": "Blue", "mn": "Хөх", "ru": "Синий"},
|
||
"블루": {"en": "Blue", "mn": "Хөх", "ru": "Синий"},
|
||
"남색": {"en": "Navy", "mn": "Хар хөх", "ru": "Тёмно-синий"},
|
||
"네이비": {"en": "Navy", "mn": "Хар хөх", "ru": "Тёмно-синий"},
|
||
"하늘색": {"en": "Sky Blue", "mn": "Тэнгэрийн хөх", "ru": "Голубой"},
|
||
"녹색": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"},
|
||
"초록색": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"},
|
||
"그린": {"en": "Green", "mn": "Ногоон", "ru": "Зелёный"},
|
||
"노랑": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"},
|
||
"노란색": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"},
|
||
"옐로우": {"en": "Yellow", "mn": "Шар", "ru": "Жёлтый"},
|
||
"주황색": {"en": "Orange", "mn": "Улбар шар", "ru": "Оранжевый"},
|
||
"오렌지": {"en": "Orange", "mn": "Улбар шар", "ru": "Оранжевый"},
|
||
"갈색": {"en": "Brown", "mn": "Бор", "ru": "Коричневый"},
|
||
"브라운": {"en": "Brown", "mn": "Бор", "ru": "Коричневый"},
|
||
"베이지": {"en": "Beige", "mn": "Бээж", "ru": "Бежевый"},
|
||
"아이보리": {"en": "Ivory", "mn": "Зүс өнгө", "ru": "Слоновая кость"},
|
||
"진주색": {"en": "Pearl", "mn": "Сувдан", "ru": "Перламутр"},
|
||
"펄화이트": {"en": "Pearl White", "mn": "Сувдан цагаан", "ru": "Перламутровый белый"},
|
||
"보라색": {"en": "Purple", "mn": "Ягаан", "ru": "Фиолетовый"},
|
||
"퍼플": {"en": "Purple", "mn": "Ягаан", "ru": "Фиолетовый"},
|
||
"분홍색": {"en": "Pink", "mn": "Ягаан", "ru": "Розовый"},
|
||
"핑크": {"en": "Pink", "mn": "Ягаан", "ru": "Розовый"},
|
||
"와인": {"en": "Wine", "mn": "Дарс өнгө", "ru": "Бордовый"},
|
||
"버건디": {"en": "Burgundy", "mn": "Бургунд", "ru": "Бургунди"},
|
||
"골드": {"en": "Gold", "mn": "Алтан", "ru": "Золотой"},
|
||
"금색": {"en": "Gold", "mn": "Алтан", "ru": "Золотой"},
|
||
"샴페인": {"en": "Champagne", "mn": "Шампан", "ru": "Шампань"},
|
||
"청색": {"en": "Blue", "mn": "Хөх", "ru": "Синий"},
|
||
"연두색": {"en": "Light Green", "mn": "Цайвар ногоон", "ru": "Светло-зелёный"},
|
||
"민트": {"en": "Mint", "mn": "Минт", "ru": "Мятный"},
|
||
"카키": {"en": "Khaki", "mn": "Хаки", "ru": "Хаки"},
|
||
"올리브": {"en": "Olive", "mn": "Чидун ногоон", "ru": "Оливковый"},
|
||
"투톤": {"en": "Two-Tone", "mn": "Хоёр өнгө", "ru": "Двухцветный"},
|
||
"기타": {"en": "Other", "mn": "Бусад", "ru": "Другой"},
|
||
},
|
||
# Car makers (제조사)
|
||
"maker": {
|
||
"현대": {"en": "Hyundai", "mn": "Хёндай", "ru": "Хендай"},
|
||
"기아": {"en": "Kia", "mn": "Киа", "ru": "Киа"},
|
||
"제네시스": {"en": "Genesis", "mn": "Женезис", "ru": "Дженезис"},
|
||
"쉐보레": {"en": "Chevrolet", "mn": "Шевроле", "ru": "Шевроле"},
|
||
"르노삼성": {"en": "Renault Samsung", "mn": "Рено Самсунг", "ru": "Рено Самсунг"},
|
||
"르노코리아": {"en": "Renault Korea", "mn": "Рено Солонгос", "ru": "Рено Корея"},
|
||
"쌍용": {"en": "SsangYong", "mn": "Ссанёнг", "ru": "СсангЙонг"},
|
||
"KG모빌리티": {"en": "KG Mobility", "mn": "KG Мобилити", "ru": "КГ Мобилити"},
|
||
"BMW": {"en": "BMW", "mn": "BMW", "ru": "БМВ"},
|
||
"벤츠": {"en": "Mercedes-Benz", "mn": "Мерседес-Бенз", "ru": "Мерседес"},
|
||
"메르세데스벤츠": {"en": "Mercedes-Benz", "mn": "Мерседес-Бенз", "ru": "Мерседес"},
|
||
"아우디": {"en": "Audi", "mn": "Ауди", "ru": "Ауди"},
|
||
"폭스바겐": {"en": "Volkswagen", "mn": "Фольксваген", "ru": "Фольксваген"},
|
||
"토요타": {"en": "Toyota", "mn": "Тойота", "ru": "Тойота"},
|
||
"혼다": {"en": "Honda", "mn": "Хонда", "ru": "Хонда"},
|
||
"닛산": {"en": "Nissan", "mn": "Ниссан", "ru": "Ниссан"},
|
||
"렉서스": {"en": "Lexus", "mn": "Лексус", "ru": "Лексус"},
|
||
"인피니티": {"en": "Infiniti", "mn": "Инфинити", "ru": "Инфинити"},
|
||
"마쯔다": {"en": "Mazda", "mn": "Мазда", "ru": "Мазда"},
|
||
"스바루": {"en": "Subaru", "mn": "Субару", "ru": "Субару"},
|
||
"미쓰비시": {"en": "Mitsubishi", "mn": "Мицубиши", "ru": "Митсубиши"},
|
||
"포드": {"en": "Ford", "mn": "Форд", "ru": "Форд"},
|
||
"링컨": {"en": "Lincoln", "mn": "Линкольн", "ru": "Линкольн"},
|
||
"캐딜락": {"en": "Cadillac", "mn": "Кадиллак", "ru": "Кадиллак"},
|
||
"지프": {"en": "Jeep", "mn": "Жийп", "ru": "Джип"},
|
||
"크라이슬러": {"en": "Chrysler", "mn": "Крайслер", "ru": "Крайслер"},
|
||
"테슬라": {"en": "Tesla", "mn": "Тесла", "ru": "Тесла"},
|
||
"볼보": {"en": "Volvo", "mn": "Вольво", "ru": "Вольво"},
|
||
"포르쉐": {"en": "Porsche", "mn": "Порше", "ru": "Порше"},
|
||
"재규어": {"en": "Jaguar", "mn": "Ягуар", "ru": "Ягуар"},
|
||
"랜드로버": {"en": "Land Rover", "mn": "Лэнд Ровер", "ru": "Лэнд Ровер"},
|
||
"미니": {"en": "Mini", "mn": "Мини", "ru": "Мини"},
|
||
"페라리": {"en": "Ferrari", "mn": "Феррари", "ru": "Феррари"},
|
||
"람보르기니": {"en": "Lamborghini", "mn": "Ламборгини", "ru": "Ламборгини"},
|
||
"벤틀리": {"en": "Bentley", "mn": "Бентли", "ru": "Бентли"},
|
||
"롤스로이스": {"en": "Rolls-Royce", "mn": "Роллс-Ройс", "ru": "Роллс-Ройс"},
|
||
"마세라티": {"en": "Maserati", "mn": "Мазерати", "ru": "Мазерати"},
|
||
"알파로메오": {"en": "Alfa Romeo", "mn": "Альфа Ромео", "ru": "Альфа Ромео"},
|
||
"피아트": {"en": "Fiat", "mn": "Фиат", "ru": "Фиат"},
|
||
"푸조": {"en": "Peugeot", "mn": "Пежо", "ru": "Пежо"},
|
||
"시트로엥": {"en": "Citroen", "mn": "Ситроен", "ru": "Ситроен"},
|
||
"르노": {"en": "Renault", "mn": "Рено", "ru": "Рено"},
|
||
"스즈키": {"en": "Suzuki", "mn": "Сузуки", "ru": "Сузуки"},
|
||
"다이하쓰": {"en": "Daihatsu", "mn": "Дайхатсу", "ru": "Дайхатсу"},
|
||
"이스즈": {"en": "Isuzu", "mn": "Исузу", "ru": "Исузу"},
|
||
"GMC": {"en": "GMC", "mn": "GMC", "ru": "ДжиЭмСи"},
|
||
"대우": {"en": "Daewoo", "mn": "Дэу", "ru": "Дэу"},
|
||
"아큐라": {"en": "Acura", "mn": "Акура", "ru": "Акура"},
|
||
"뷰익": {"en": "Buick", "mn": "Бюик", "ru": "Бьюик"},
|
||
"스마트": {"en": "Smart", "mn": "Смарт", "ru": "Смарт"},
|
||
"BYD": {"en": "BYD", "mn": "BYD", "ru": "БИД"},
|
||
},
|
||
# Car models (차량 모델) - These generally keep their names but with transliteration
|
||
"model": {
|
||
# Hyundai models
|
||
"소나타": {"en": "Sonata", "mn": "Соната", "ru": "Соната"},
|
||
"아반떼": {"en": "Avante", "mn": "Аванте", "ru": "Аванте"},
|
||
"그랜저": {"en": "Grandeur", "mn": "Грандер", "ru": "Грандер"},
|
||
"싼타페": {"en": "Santa Fe", "mn": "Санта Фе", "ru": "Санта Фе"},
|
||
"투싼": {"en": "Tucson", "mn": "Туксон", "ru": "Туксон"},
|
||
"팰리세이드": {"en": "Palisade", "mn": "Палисейд", "ru": "Палисад"},
|
||
"스타리아": {"en": "Staria", "mn": "Стариа", "ru": "Стариа"},
|
||
"아이오닉": {"en": "Ioniq", "mn": "Ионик", "ru": "Ионик"},
|
||
"코나": {"en": "Kona", "mn": "Кона", "ru": "Кона"},
|
||
"베뉴": {"en": "Venue", "mn": "Венью", "ru": "Венью"},
|
||
"넥쏘": {"en": "Nexo", "mn": "Нексо", "ru": "Нексо"},
|
||
"캐스퍼": {"en": "Casper", "mn": "Каспер", "ru": "Каспер"},
|
||
"엘란트라": {"en": "Elantra", "mn": "Элантра", "ru": "Элантра"},
|
||
"액센트": {"en": "Accent", "mn": "Акцент", "ru": "Акцент"},
|
||
"벨로스터": {"en": "Veloster", "mn": "Велостер", "ru": "Велостер"},
|
||
"i30": {"en": "i30", "mn": "i30", "ru": "i30"},
|
||
"i40": {"en": "i40", "mn": "i40", "ru": "i40"},
|
||
# Kia models
|
||
"쏘렌토": {"en": "Sorento", "mn": "Соренто", "ru": "Соренто"},
|
||
"스포티지": {"en": "Sportage", "mn": "Спортейж", "ru": "Спортейдж"},
|
||
"카니발": {"en": "Carnival", "mn": "Карнивал", "ru": "Карнивал"},
|
||
"셀토스": {"en": "Seltos", "mn": "Сельтос", "ru": "Селтос"},
|
||
"모하비": {"en": "Mohave", "mn": "Мохаве", "ru": "Мохаве"},
|
||
"니로": {"en": "Niro", "mn": "Ниро", "ru": "Ниро"},
|
||
"스팅어": {"en": "Stinger", "mn": "Стингер", "ru": "Стингер"},
|
||
"레이": {"en": "Ray", "mn": "Рэй", "ru": "Рэй"},
|
||
"모닝": {"en": "Morning", "mn": "Морнинг", "ru": "Морнинг"},
|
||
"쏘울": {"en": "Soul", "mn": "Соул", "ru": "Соул"},
|
||
"K3": {"en": "K3", "mn": "K3", "ru": "K3"},
|
||
"K5": {"en": "K5", "mn": "K5", "ru": "K5"},
|
||
"K7": {"en": "K7", "mn": "K7", "ru": "K7"},
|
||
"K8": {"en": "K8", "mn": "K8", "ru": "K8"},
|
||
"K9": {"en": "K9", "mn": "K9", "ru": "K9"},
|
||
"EV6": {"en": "EV6", "mn": "EV6", "ru": "EV6"},
|
||
"EV9": {"en": "EV9", "mn": "EV9", "ru": "EV9"},
|
||
# Genesis models
|
||
"G70": {"en": "G70", "mn": "G70", "ru": "G70"},
|
||
"G80": {"en": "G80", "mn": "G80", "ru": "G80"},
|
||
"G90": {"en": "G90", "mn": "G90", "ru": "G90"},
|
||
"GV60": {"en": "GV60", "mn": "GV60", "ru": "GV60"},
|
||
"GV70": {"en": "GV70", "mn": "GV70", "ru": "GV70"},
|
||
"GV80": {"en": "GV80", "mn": "GV80", "ru": "GV80"},
|
||
# SsangYong / KG Mobility models
|
||
"렉스턴": {"en": "Rexton", "mn": "Рекстон", "ru": "Рекстон"},
|
||
"코란도": {"en": "Korando", "mn": "Корандо", "ru": "Корандо"},
|
||
"티볼리": {"en": "Tivoli", "mn": "Тиволи", "ru": "Тиволи"},
|
||
"토레스": {"en": "Torres", "mn": "Торрес", "ru": "Торрес"},
|
||
"무쏘": {"en": "Musso", "mn": "Муссо", "ru": "Муссо"},
|
||
# Chevrolet models
|
||
"말리부": {"en": "Malibu", "mn": "Малибу", "ru": "Малибу"},
|
||
"트래버스": {"en": "Traverse", "mn": "Траверс", "ru": "Траверс"},
|
||
"트랙스": {"en": "Trax", "mn": "Тракс", "ru": "Тракс"},
|
||
"이쿼녹스": {"en": "Equinox", "mn": "Эквинокс", "ru": "Эквинокс"},
|
||
"스파크": {"en": "Spark", "mn": "Спарк", "ru": "Спарк"},
|
||
"볼트": {"en": "Bolt", "mn": "Болт", "ru": "Болт"},
|
||
# Renault Samsung models
|
||
"SM6": {"en": "SM6", "mn": "SM6", "ru": "SM6"},
|
||
"QM6": {"en": "QM6", "mn": "QM6", "ru": "QM6"},
|
||
"XM3": {"en": "XM3", "mn": "XM3", "ru": "XM3"},
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/categories")
|
||
def get_categories():
|
||
"""Get available translation categories"""
|
||
return TRANSLATION_CATEGORIES
|
||
|
||
|
||
@router.get("", response_model=TranslationListResponse)
|
||
def get_translations(
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(50, ge=1, le=200),
|
||
category: Optional[str] = None,
|
||
search: Optional[str] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""Get all translations with pagination and filtering"""
|
||
query = db.query(Translation)
|
||
|
||
if category:
|
||
query = query.filter(Translation.category == category)
|
||
|
||
if search:
|
||
search_term = f"%{search}%"
|
||
query = query.filter(
|
||
or_(
|
||
Translation.source_text.ilike(search_term),
|
||
Translation.text_en.ilike(search_term),
|
||
Translation.text_mn.ilike(search_term),
|
||
Translation.text_ru.ilike(search_term),
|
||
)
|
||
)
|
||
|
||
total = query.count()
|
||
translations = query.order_by(
|
||
Translation.category, Translation.source_text
|
||
).offset((page - 1) * page_size).limit(page_size).all()
|
||
|
||
return TranslationListResponse(
|
||
total=total,
|
||
page=page,
|
||
page_size=page_size,
|
||
translations=translations
|
||
)
|
||
|
||
|
||
@router.get("/{translation_id}", response_model=TranslationResponse)
|
||
def get_translation(translation_id: int, db: Session = Depends(get_db)):
|
||
"""Get a specific translation by ID"""
|
||
translation = db.query(Translation).filter(Translation.id == translation_id).first()
|
||
if not translation:
|
||
raise HTTPException(status_code=404, detail="Translation not found")
|
||
return translation
|
||
|
||
|
||
@router.post("", response_model=TranslationResponse)
|
||
def create_translation(data: TranslationCreate, db: Session = Depends(get_db)):
|
||
"""Create a new translation"""
|
||
# Check if already exists
|
||
existing = db.query(Translation).filter(
|
||
Translation.source_text == data.source_text,
|
||
Translation.category == data.category
|
||
).first()
|
||
|
||
if existing:
|
||
raise HTTPException(status_code=400, detail="Translation already exists for this text and category")
|
||
|
||
translation = Translation(**data.dict())
|
||
db.add(translation)
|
||
db.commit()
|
||
db.refresh(translation)
|
||
return translation
|
||
|
||
|
||
@router.put("/{translation_id}", response_model=TranslationResponse)
|
||
def update_translation(
|
||
translation_id: int,
|
||
data: TranslationUpdate,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Update a translation"""
|
||
translation = db.query(Translation).filter(Translation.id == translation_id).first()
|
||
if not translation:
|
||
raise HTTPException(status_code=404, detail="Translation not found")
|
||
|
||
for key, value in data.dict(exclude_unset=True).items():
|
||
setattr(translation, key, value)
|
||
|
||
db.commit()
|
||
db.refresh(translation)
|
||
return translation
|
||
|
||
|
||
@router.delete("/{translation_id}")
|
||
def delete_translation(translation_id: int, db: Session = Depends(get_db)):
|
||
"""Delete a translation"""
|
||
translation = db.query(Translation).filter(Translation.id == translation_id).first()
|
||
if not translation:
|
||
raise HTTPException(status_code=404, detail="Translation not found")
|
||
|
||
db.delete(translation)
|
||
db.commit()
|
||
return {"message": "Translation deleted"}
|
||
|
||
|
||
@router.post("/bulk-lookup", response_model=TranslationBulkResponse)
|
||
def bulk_lookup(
|
||
request: TranslationBulkRequest,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Lookup translations for multiple texts at once"""
|
||
if not request.texts:
|
||
return TranslationBulkResponse(translations={})
|
||
|
||
query = db.query(Translation).filter(Translation.source_text.in_(request.texts))
|
||
|
||
if request.category:
|
||
query = query.filter(Translation.category == request.category)
|
||
|
||
translations_db = query.all()
|
||
|
||
# Build result dictionary
|
||
result = {}
|
||
lang_field = f"text_{request.lang}"
|
||
|
||
for trans in translations_db:
|
||
translated = getattr(trans, lang_field, None)
|
||
if translated:
|
||
result[trans.source_text] = translated
|
||
else:
|
||
# Fallback to source text if no translation
|
||
result[trans.source_text] = trans.source_text
|
||
|
||
# For texts not found in DB, return original
|
||
for text in request.texts:
|
||
if text not in result:
|
||
result[text] = text
|
||
|
||
return TranslationBulkResponse(translations=result)
|
||
|
||
|
||
@router.get("/lookup/{text}")
|
||
def lookup_single(
|
||
text: str,
|
||
lang: str = Query("en", description="Target language: en, mn, ru"),
|
||
category: Optional[str] = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""Lookup translation for a single text"""
|
||
query = db.query(Translation).filter(Translation.source_text == text)
|
||
|
||
if category:
|
||
query = query.filter(Translation.category == category)
|
||
|
||
translation = query.first()
|
||
|
||
if not translation:
|
||
return {"source": text, "translated": text}
|
||
|
||
lang_field = f"text_{lang}"
|
||
translated = getattr(translation, lang_field, None)
|
||
|
||
return {
|
||
"source": text,
|
||
"translated": translated if translated else text,
|
||
"category": translation.category
|
||
}
|
||
|
||
|
||
def get_default_translation(source_text: str, category: str) -> dict:
|
||
"""Get default translations from the predefined dictionary"""
|
||
if category in DEFAULT_TRANSLATIONS:
|
||
return DEFAULT_TRANSLATIONS[category].get(source_text, {})
|
||
return {}
|
||
|
||
|
||
# Korean year suffix translations
|
||
YEAR_SUFFIX_TRANSLATIONS = {
|
||
"년형": {"en": "", "mn": " он", "ru": " г."},
|
||
"년식": {"en": "", "mn": " он", "ru": " г."},
|
||
"년": {"en": "", "mn": " он", "ru": " г."},
|
||
}
|
||
|
||
|
||
def translate_car_name(car_name: str, lang: str, db) -> str:
|
||
"""
|
||
Translate a car name like "기아 K5 2024년형" to "Kia K5 2024"
|
||
by translating each component separately.
|
||
"""
|
||
import re
|
||
|
||
if not car_name:
|
||
return car_name
|
||
|
||
result = car_name
|
||
|
||
# First, look up all translations we have in DB
|
||
all_translations = db.query(Translation).filter(
|
||
Translation.category.in_(["maker", "model", "fuel", "transmission", "color"])
|
||
).all()
|
||
|
||
# Build lookup dict
|
||
trans_lookup = {}
|
||
for t in all_translations:
|
||
lang_field = f"text_{lang}"
|
||
translated = getattr(t, lang_field, None)
|
||
if translated and t.source_text:
|
||
trans_lookup[t.source_text] = translated
|
||
|
||
# Also add from default dictionary
|
||
for category in ["maker", "model", "fuel", "transmission", "color"]:
|
||
if category in DEFAULT_TRANSLATIONS:
|
||
for korean, translations in DEFAULT_TRANSLATIONS[category].items():
|
||
if korean not in trans_lookup and lang in translations:
|
||
trans_lookup[korean] = translations[lang]
|
||
|
||
# Replace Korean terms with translations (longest first to avoid partial matches)
|
||
sorted_keys = sorted(trans_lookup.keys(), key=len, reverse=True)
|
||
for korean in sorted_keys:
|
||
if korean in result:
|
||
result = result.replace(korean, trans_lookup[korean])
|
||
|
||
# Handle year format: "2024년형" -> "2024" (en) or "2024 он" (mn) or "2024 г." (ru)
|
||
for suffix, translations in YEAR_SUFFIX_TRANSLATIONS.items():
|
||
pattern = r'(\d{4})' + re.escape(suffix)
|
||
replacement = r'\1' + translations.get(lang, "")
|
||
result = re.sub(pattern, replacement, result)
|
||
|
||
# Clean up extra spaces
|
||
result = re.sub(r'\s+', ' ', result).strip()
|
||
|
||
return result
|
||
|
||
|
||
@router.post("/auto-extract")
|
||
def auto_extract_terms(db: Session = Depends(get_db)):
|
||
"""
|
||
Auto-extract unique terms from cars database that need translation.
|
||
This creates translation entries for makers, models, fuels, colors, etc.
|
||
Auto-fills with default translations from built-in dictionary.
|
||
"""
|
||
from ..models import Car, CarMaker, CarModel
|
||
|
||
added_count = 0
|
||
# Track already added entries in this batch to avoid duplicates
|
||
added_keys = set()
|
||
|
||
def add_translation(source_text: str, category: str, text_en: str, text_mn: str, text_ru: str):
|
||
nonlocal added_count
|
||
key = (source_text, category)
|
||
if key in added_keys:
|
||
return # Skip duplicate in same batch
|
||
|
||
existing = db.query(Translation).filter(
|
||
Translation.source_text == source_text,
|
||
Translation.category == category
|
||
).first()
|
||
if not existing:
|
||
trans = Translation(
|
||
source_text=source_text,
|
||
category=category,
|
||
text_en=text_en,
|
||
text_mn=text_mn,
|
||
text_ru=text_ru
|
||
)
|
||
db.add(trans)
|
||
added_keys.add(key)
|
||
added_count += 1
|
||
|
||
# Extract makers
|
||
makers = db.query(CarMaker).all()
|
||
for maker in makers:
|
||
if maker.name:
|
||
defaults = get_default_translation(maker.name, "maker")
|
||
add_translation(
|
||
maker.name,
|
||
"maker",
|
||
defaults.get("en") or maker.name_en or maker.name,
|
||
defaults.get("mn") or maker.name,
|
||
defaults.get("ru") or maker.name
|
||
)
|
||
|
||
# Extract models
|
||
models = db.query(CarModel).all()
|
||
for model in models:
|
||
if model.name:
|
||
defaults = get_default_translation(model.name, "model")
|
||
add_translation(
|
||
model.name,
|
||
"model",
|
||
defaults.get("en") or model.name_en or model.name,
|
||
defaults.get("mn") or model.name_en or model.name,
|
||
defaults.get("ru") or model.name_en or model.name
|
||
)
|
||
|
||
# Extract unique fuels
|
||
fuels = db.query(Car.fuel).distinct().filter(Car.fuel.isnot(None)).all()
|
||
for (fuel,) in fuels:
|
||
if fuel:
|
||
defaults = get_default_translation(fuel, "fuel")
|
||
add_translation(
|
||
fuel,
|
||
"fuel",
|
||
defaults.get("en") or fuel,
|
||
defaults.get("mn") or fuel,
|
||
defaults.get("ru") or fuel
|
||
)
|
||
|
||
# Extract unique transmissions
|
||
transmissions = db.query(Car.transmission).distinct().filter(Car.transmission.isnot(None)).all()
|
||
for (trans_type,) in transmissions:
|
||
if trans_type:
|
||
defaults = get_default_translation(trans_type, "transmission")
|
||
add_translation(
|
||
trans_type,
|
||
"transmission",
|
||
defaults.get("en") or trans_type,
|
||
defaults.get("mn") or trans_type,
|
||
defaults.get("ru") or trans_type
|
||
)
|
||
|
||
# Extract unique colors
|
||
colors = db.query(Car.color).distinct().filter(Car.color.isnot(None)).all()
|
||
for (color,) in colors:
|
||
if color:
|
||
defaults = get_default_translation(color, "color")
|
||
add_translation(
|
||
color,
|
||
"color",
|
||
defaults.get("en") or color,
|
||
defaults.get("mn") or color,
|
||
defaults.get("ru") or color
|
||
)
|
||
|
||
# Extract unique car_names and auto-translate
|
||
car_names = db.query(Car.car_name).distinct().filter(Car.car_name.isnot(None)).all()
|
||
for (car_name,) in car_names:
|
||
if car_name:
|
||
key = (car_name, "car_name")
|
||
if key not in added_keys:
|
||
existing = db.query(Translation).filter(
|
||
Translation.source_text == car_name,
|
||
Translation.category == "car_name"
|
||
).first()
|
||
if not existing:
|
||
# Auto-translate car name by translating each component
|
||
translated_en = translate_car_name(car_name, "en", db)
|
||
translated_mn = translate_car_name(car_name, "mn", db)
|
||
translated_ru = translate_car_name(car_name, "ru", db)
|
||
|
||
trans = Translation(
|
||
source_text=car_name,
|
||
category="car_name",
|
||
text_en=translated_en,
|
||
text_mn=translated_mn,
|
||
text_ru=translated_ru
|
||
)
|
||
db.add(trans)
|
||
added_keys.add(key)
|
||
added_count += 1
|
||
|
||
db.commit()
|
||
|
||
return {"message": f"Added {added_count} new translation entries with default translations"}
|
||
|
||
|
||
@router.post("/fill-defaults")
|
||
def fill_default_translations(db: Session = Depends(get_db)):
|
||
"""
|
||
Fill in missing translations with default values from the built-in dictionary.
|
||
This updates existing entries that have null translations.
|
||
"""
|
||
updated_count = 0
|
||
|
||
# Get all translations that have at least one null translation
|
||
translations = db.query(Translation).filter(
|
||
(Translation.text_en.is_(None)) |
|
||
(Translation.text_mn.is_(None)) |
|
||
(Translation.text_ru.is_(None))
|
||
).all()
|
||
|
||
for trans in translations:
|
||
defaults = get_default_translation(trans.source_text, trans.category)
|
||
updated = False
|
||
|
||
if not trans.text_en and defaults.get("en"):
|
||
trans.text_en = defaults["en"]
|
||
updated = True
|
||
elif not trans.text_en:
|
||
# Use source text as fallback for non-Korean languages
|
||
trans.text_en = trans.source_text
|
||
updated = True
|
||
|
||
if not trans.text_mn and defaults.get("mn"):
|
||
trans.text_mn = defaults["mn"]
|
||
updated = True
|
||
elif not trans.text_mn:
|
||
trans.text_mn = trans.source_text
|
||
updated = True
|
||
|
||
if not trans.text_ru and defaults.get("ru"):
|
||
trans.text_ru = defaults["ru"]
|
||
updated = True
|
||
elif not trans.text_ru:
|
||
trans.text_ru = trans.source_text
|
||
updated = True
|
||
|
||
if updated:
|
||
updated_count += 1
|
||
|
||
db.commit()
|
||
|
||
return {"message": f"Updated {updated_count} translations with default values"}
|
||
|
||
|
||
@router.post("/seed-all-defaults")
|
||
def seed_all_default_translations(db: Session = Depends(get_db)):
|
||
"""
|
||
Seed ALL translations from the DEFAULT_TRANSLATIONS dictionary into the database.
|
||
This pre-populates translations for all known terms, not just those in the cars table.
|
||
"""
|
||
added_count = 0
|
||
skipped_count = 0
|
||
|
||
for category, terms in DEFAULT_TRANSLATIONS.items():
|
||
for korean_text, translations in terms.items():
|
||
# Check if already exists
|
||
existing = db.query(Translation).filter(
|
||
Translation.source_text == korean_text,
|
||
Translation.category == category
|
||
).first()
|
||
|
||
if existing:
|
||
skipped_count += 1
|
||
continue
|
||
|
||
trans = Translation(
|
||
source_text=korean_text,
|
||
category=category,
|
||
text_en=translations.get("en", korean_text),
|
||
text_mn=translations.get("mn", korean_text),
|
||
text_ru=translations.get("ru", korean_text)
|
||
)
|
||
db.add(trans)
|
||
added_count += 1
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"message": f"Seeded {added_count} translations from default dictionary (skipped {skipped_count} existing)",
|
||
"added": added_count,
|
||
"skipped": skipped_count,
|
||
"categories": list(DEFAULT_TRANSLATIONS.keys())
|
||
}
|
||
|
||
|
||
# AI Auto-Translation Service
|
||
import httpx
|
||
import json
|
||
from typing import Dict, List
|
||
import asyncio
|
||
|
||
# Translation service configuration
|
||
TRANSLATION_SERVICE = {
|
||
"enabled": True,
|
||
"provider": "google", # Options: "google", "deepl", "libre"
|
||
"api_key": None, # Set via environment variable
|
||
"libre_url": "https://libretranslate.com/translate", # Free option
|
||
}
|
||
|
||
|
||
async def translate_text_ai(text: str, source_lang: str, target_lang: str) -> str:
|
||
"""
|
||
Translate text using AI translation service.
|
||
Falls back to original text if translation fails.
|
||
"""
|
||
if not text or not TRANSLATION_SERVICE["enabled"]:
|
||
return text
|
||
|
||
# Language code mapping
|
||
lang_map = {
|
||
"ko": "ko",
|
||
"en": "en",
|
||
"mn": "mn",
|
||
"ru": "ru",
|
||
}
|
||
|
||
source = lang_map.get(source_lang, source_lang)
|
||
target = lang_map.get(target_lang, target_lang)
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
# Using LibreTranslate (free option)
|
||
response = await client.post(
|
||
TRANSLATION_SERVICE["libre_url"],
|
||
json={
|
||
"q": text,
|
||
"source": source,
|
||
"target": target,
|
||
"format": "text"
|
||
},
|
||
headers={"Content-Type": "application/json"}
|
||
)
|
||
|
||
if response.status_code == 200:
|
||
result = response.json()
|
||
return result.get("translatedText", text)
|
||
else:
|
||
return text
|
||
except Exception as e:
|
||
print(f"Translation error: {e}")
|
||
return text
|
||
|
||
|
||
def translate_text_sync(text: str, source_lang: str, target_lang: str) -> str:
|
||
"""Synchronous wrapper for AI translation"""
|
||
try:
|
||
return asyncio.run(translate_text_ai(text, source_lang, target_lang))
|
||
except RuntimeError:
|
||
# Already in an event loop
|
||
loop = asyncio.get_event_loop()
|
||
if loop.is_running():
|
||
# Can't run async in sync context when loop is running
|
||
return text
|
||
return loop.run_until_complete(translate_text_ai(text, source_lang, target_lang))
|
||
|
||
|
||
from pydantic import BaseModel
|
||
from typing import List as PyList
|
||
|
||
class AutoTranslateRequest(BaseModel):
|
||
target_langs: PyList[str] = ["en", "mn", "ru"]
|
||
|
||
@router.post("/auto-translate/{translation_id}")
|
||
def auto_translate_single(
|
||
translation_id: int,
|
||
request: AutoTranslateRequest = None,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
Auto-translate a single translation entry using AI.
|
||
Translates Korean source text to all target languages.
|
||
"""
|
||
if request is None:
|
||
request = AutoTranslateRequest()
|
||
|
||
translation = db.query(Translation).filter(Translation.id == translation_id).first()
|
||
if not translation:
|
||
raise HTTPException(status_code=404, detail="Translation not found")
|
||
|
||
source_text = translation.source_text
|
||
translations_result = {}
|
||
|
||
# First check defaults
|
||
defaults = get_default_translation(source_text, translation.category)
|
||
|
||
# Try to translate to requested languages
|
||
if "en" in request.target_langs:
|
||
if not translation.text_en or translation.text_en == source_text:
|
||
if defaults.get("en"):
|
||
translation.text_en = defaults["en"]
|
||
else:
|
||
translated = translate_text_sync(source_text, "ko", "en")
|
||
if translated != source_text:
|
||
translation.text_en = translated
|
||
translations_result["en"] = translation.text_en or ""
|
||
|
||
if "mn" in request.target_langs:
|
||
if not translation.text_mn or translation.text_mn == source_text:
|
||
if defaults.get("mn"):
|
||
translation.text_mn = defaults["mn"]
|
||
else:
|
||
translated = translate_text_sync(source_text, "ko", "mn")
|
||
if translated != source_text:
|
||
translation.text_mn = translated
|
||
translations_result["mn"] = translation.text_mn or ""
|
||
|
||
if "ru" in request.target_langs:
|
||
if not translation.text_ru or translation.text_ru == source_text:
|
||
if defaults.get("ru"):
|
||
translation.text_ru = defaults["ru"]
|
||
else:
|
||
translated = translate_text_sync(source_text, "ko", "ru")
|
||
if translated != source_text:
|
||
translation.text_ru = translated
|
||
translations_result["ru"] = translation.text_ru or ""
|
||
|
||
db.commit()
|
||
db.refresh(translation)
|
||
|
||
return {
|
||
"id": translation.id,
|
||
"source_text": translation.source_text,
|
||
"translations": translations_result,
|
||
"message": f"Auto-translated to: {', '.join(request.target_langs)}"
|
||
}
|
||
|
||
|
||
class BatchTranslateRequest(BaseModel):
|
||
target_langs: PyList[str] = ["en", "mn", "ru"]
|
||
category: Optional[str] = None
|
||
overwrite_existing: bool = False
|
||
|
||
@router.post("/auto-translate-batch")
|
||
def auto_translate_batch(
|
||
request: BatchTranslateRequest = None,
|
||
limit: int = Query(100, ge=1, le=500),
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
Auto-translate multiple translations that have missing translations.
|
||
Limits batch size to avoid API rate limits.
|
||
"""
|
||
if request is None:
|
||
request = BatchTranslateRequest()
|
||
|
||
# Build query based on options
|
||
if request.overwrite_existing:
|
||
# Get all translations (optionally filtered by category)
|
||
query = db.query(Translation)
|
||
else:
|
||
# Only get translations with missing values
|
||
query = db.query(Translation).filter(
|
||
(Translation.text_en.is_(None)) |
|
||
(Translation.text_mn.is_(None)) |
|
||
(Translation.text_ru.is_(None)) |
|
||
(Translation.text_en == Translation.source_text) |
|
||
(Translation.text_mn == Translation.source_text) |
|
||
(Translation.text_ru == Translation.source_text) |
|
||
(Translation.text_en == "") |
|
||
(Translation.text_mn == "") |
|
||
(Translation.text_ru == "")
|
||
)
|
||
|
||
if request.category:
|
||
query = query.filter(Translation.category == request.category)
|
||
|
||
translations = query.limit(limit).all()
|
||
|
||
successful = 0
|
||
failed = 0
|
||
results = []
|
||
|
||
for trans in translations:
|
||
source_text = trans.source_text
|
||
try:
|
||
updated = False
|
||
|
||
# First check if we have default translations
|
||
defaults = get_default_translation(source_text, trans.category)
|
||
|
||
# English
|
||
if "en" in request.target_langs:
|
||
if request.overwrite_existing or not trans.text_en or trans.text_en == source_text or trans.text_en == "":
|
||
if defaults.get("en"):
|
||
trans.text_en = defaults["en"]
|
||
updated = True
|
||
else:
|
||
translated = translate_text_sync(source_text, "ko", "en")
|
||
if translated and translated != source_text:
|
||
trans.text_en = translated
|
||
updated = True
|
||
|
||
# Mongolian
|
||
if "mn" in request.target_langs:
|
||
if request.overwrite_existing or not trans.text_mn or trans.text_mn == source_text or trans.text_mn == "":
|
||
if defaults.get("mn"):
|
||
trans.text_mn = defaults["mn"]
|
||
updated = True
|
||
else:
|
||
translated = translate_text_sync(source_text, "ko", "mn")
|
||
if translated and translated != source_text:
|
||
trans.text_mn = translated
|
||
updated = True
|
||
|
||
# Russian
|
||
if "ru" in request.target_langs:
|
||
if request.overwrite_existing or not trans.text_ru or trans.text_ru == source_text or trans.text_ru == "":
|
||
if defaults.get("ru"):
|
||
trans.text_ru = defaults["ru"]
|
||
updated = True
|
||
else:
|
||
translated = translate_text_sync(source_text, "ko", "ru")
|
||
if translated and translated != source_text:
|
||
trans.text_ru = translated
|
||
updated = True
|
||
|
||
if updated:
|
||
successful += 1
|
||
results.append({
|
||
"id": trans.id,
|
||
"source_text": source_text,
|
||
"success": True
|
||
})
|
||
else:
|
||
results.append({
|
||
"id": trans.id,
|
||
"source_text": source_text,
|
||
"success": True,
|
||
"error": "No changes needed"
|
||
})
|
||
|
||
except Exception as e:
|
||
failed += 1
|
||
results.append({
|
||
"id": trans.id,
|
||
"source_text": source_text,
|
||
"success": False,
|
||
"error": str(e)
|
||
})
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"total_processed": len(translations),
|
||
"successful": successful,
|
||
"failed": failed,
|
||
"results": results
|
||
}
|
||
|
||
|
||
class TranslateOnDemandRequest(BaseModel):
|
||
text: str
|
||
source_lang: str = "ko"
|
||
target_lang: str = "en"
|
||
|
||
@router.post("/translate-on-demand")
|
||
def translate_on_demand(
|
||
request: TranslateOnDemandRequest,
|
||
db: Session = Depends(get_db)
|
||
):
|
||
"""
|
||
Translate a single text on-demand without saving to database.
|
||
Useful for dynamic content translation.
|
||
"""
|
||
text = request.text
|
||
target_lang = request.target_lang
|
||
|
||
if not text:
|
||
return {"source_text": "", "translated_text": "", "source_lang": request.source_lang, "target_lang": target_lang}
|
||
|
||
translated_text = text
|
||
|
||
# First, check existing translations in DB
|
||
trans = db.query(Translation).filter(
|
||
Translation.source_text == text
|
||
).first()
|
||
|
||
if trans:
|
||
lang_field = f"text_{target_lang}"
|
||
translated = getattr(trans, lang_field, None)
|
||
if translated and translated != text:
|
||
translated_text = translated
|
||
else:
|
||
# Check default dictionary
|
||
for category in DEFAULT_TRANSLATIONS:
|
||
if text in DEFAULT_TRANSLATIONS[category]:
|
||
if target_lang in DEFAULT_TRANSLATIONS[category][text]:
|
||
translated_text = DEFAULT_TRANSLATIONS[category][text][target_lang]
|
||
break
|
||
|
||
# If still not found, try AI translation
|
||
if translated_text == text:
|
||
translated_text = translate_text_sync(text, request.source_lang, target_lang)
|
||
|
||
return {
|
||
"source_text": text,
|
||
"translated_text": translated_text,
|
||
"source_lang": request.source_lang,
|
||
"target_lang": target_lang
|
||
}
|
||
|
||
|
||
@router.get("/stats")
|
||
def get_translation_stats(db: Session = Depends(get_db)):
|
||
"""Get translation statistics"""
|
||
total = db.query(Translation).count()
|
||
|
||
# Count by category
|
||
from sqlalchemy import func
|
||
by_category = db.query(
|
||
Translation.category,
|
||
func.count(Translation.id)
|
||
).group_by(Translation.category).all()
|
||
|
||
# Count missing translations (null or same as source)
|
||
missing_en = db.query(Translation).filter(
|
||
(Translation.text_en.is_(None)) |
|
||
(Translation.text_en == Translation.source_text) |
|
||
(Translation.text_en == "")
|
||
).count()
|
||
|
||
missing_mn = db.query(Translation).filter(
|
||
(Translation.text_mn.is_(None)) |
|
||
(Translation.text_mn == Translation.source_text) |
|
||
(Translation.text_mn == "")
|
||
).count()
|
||
|
||
missing_ru = db.query(Translation).filter(
|
||
(Translation.text_ru.is_(None)) |
|
||
(Translation.text_ru == Translation.source_text) |
|
||
(Translation.text_ru == "")
|
||
).count()
|
||
|
||
translated_en = total - missing_en
|
||
translated_mn = total - missing_mn
|
||
translated_ru = total - missing_ru
|
||
|
||
return {
|
||
"total_entries": total,
|
||
"by_category": {cat: count for cat, count in by_category},
|
||
"translation_coverage": {
|
||
"english": {
|
||
"translated": translated_en,
|
||
"total": total,
|
||
"percentage": round(translated_en / total * 100, 1) if total > 0 else 0
|
||
},
|
||
"mongolian": {
|
||
"translated": translated_mn,
|
||
"total": total,
|
||
"percentage": round(translated_mn / total * 100, 1) if total > 0 else 0
|
||
},
|
||
"russian": {
|
||
"translated": translated_ru,
|
||
"total": total,
|
||
"percentage": round(translated_ru / total * 100, 1) if total > 0 else 0
|
||
}
|
||
}
|
||
}
|