fix: banner translations and deployment improvements
- Add translateCarName import from i18n.ts for proper multilingual support - Change default API language from 'ko' to 'en' for hero banners - Add checkbox column for Local Cars banner registration - Update Dockerfile with Playwright dependencies - Add PostgreSQL migration script - Add banner translation fix script 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,37 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
# Install system dependencies for PostgreSQL and Playwright
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
# Playwright dependencies
|
||||
libnss3 \
|
||||
libnspr4 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libdbus-1-3 \
|
||||
libxkbcommon0 \
|
||||
libatspi2.0-0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libasound2 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Playwright browsers (Chromium only for smaller image)
|
||||
RUN playwright install chromium
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ def get_localized_field(obj, field: str, lang: str) -> Optional[str]:
|
||||
|
||||
@router.get("/", response_model=List[HeroBannerLocalizedResponse])
|
||||
def get_hero_banners(
|
||||
lang: str = Query("ko", regex="^(ko|en|mn|ru)$"),
|
||||
lang: str = Query("en", regex="^(ko|en|mn|ru)$"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""활성 히어로 배너 목록 조회 (Public)"""
|
||||
|
||||
@@ -64,7 +64,10 @@ class Settings(BaseSettings):
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
db_path = os.path.join(base_dir, "autonet.db")
|
||||
return f"sqlite:///{db_path}"
|
||||
return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||
# URL-encode password for special characters like @ # etc
|
||||
from urllib.parse import quote_plus
|
||||
encoded_password = quote_plus(self.DB_PASSWORD)
|
||||
return f"postgresql://{self.DB_USER}:{encoded_password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||
|
||||
@property
|
||||
def REDIS_URL(self) -> str:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import RedirectResponse
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
import asyncio
|
||||
@@ -15,6 +17,42 @@ from datetime import datetime, timedelta
|
||||
|
||||
app_settings = get_settings()
|
||||
|
||||
|
||||
class TrailingSlashMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Middleware to normalize trailing slashes on API paths.
|
||||
Uses redirect to strip trailing slashes from non-root routes.
|
||||
|
||||
Routes defined with "/" (like /hero-banners/) keep trailing slash.
|
||||
Routes defined without "/" (like /cars) get redirected.
|
||||
"""
|
||||
# Routes that are defined WITH trailing slash (router.get("/"))
|
||||
TRAILING_SLASH_ROUTES = {
|
||||
"/api/hero-banners/",
|
||||
"/api/settings/",
|
||||
"/api/notifications/",
|
||||
"/api/vehicle-requests/",
|
||||
}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
# Skip if it's a known trailing-slash route
|
||||
if path in self.TRAILING_SLASH_ROUTES:
|
||||
return await call_next(request)
|
||||
|
||||
# Redirect trailing slash from other /api/* paths
|
||||
if path.startswith("/api/") and path.endswith("/") and len(path) > 5:
|
||||
new_path = path.rstrip("/")
|
||||
if request.url.query:
|
||||
new_url = f"{new_path}?{request.url.query}"
|
||||
else:
|
||||
new_url = new_path
|
||||
return RedirectResponse(url=new_url, status_code=307)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@@ -118,6 +156,9 @@ app = FastAPI(
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Trailing slash middleware (must be added before CORS)
|
||||
app.add_middleware(TrailingSlashMiddleware)
|
||||
|
||||
# CORS - credentials=True requires explicit origins (not "*")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -126,6 +167,8 @@ app.add_middleware(
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:8000",
|
||||
"http://192.168.0.202:3000", # Local network
|
||||
"https://autonetsellcar.com", # Production
|
||||
"http://autonetsellcar.com", # Production (HTTP redirect)
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
|
||||
124
backend/fix_banner_translations.py
Normal file
124
backend/fix_banner_translations.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fix hero banner translations by updating title_en, title_mn, title_ru from title_ko
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# Translation dictionary (subset of key terms)
|
||||
TRANSLATIONS = {
|
||||
# Makers
|
||||
'현대': {'en': 'Hyundai', 'mn': 'Хёндай', 'ru': 'Хёндай'},
|
||||
'기아': {'en': 'Kia', 'mn': 'Киа', 'ru': 'Киа'},
|
||||
'제네시스': {'en': 'Genesis', 'mn': 'Женезис', 'ru': 'Дженезис'},
|
||||
'쉐보레': {'en': 'Chevrolet', 'mn': 'Шевроле', 'ru': 'Шевроле'},
|
||||
'KG모빌리티': {'en': 'KG Mobility', 'mn': 'КЖ Мобилити', 'ru': 'КГ Мобилити'},
|
||||
'쌍용': {'en': 'SsangYong', 'mn': 'СсангЁнг', 'ru': 'СсангЙонг'},
|
||||
|
||||
# Models
|
||||
'모하비': {'en': 'Mohave', 'mn': 'Мохаве', 'ru': 'Мохаве'},
|
||||
'더 마스터': {'en': 'The Master', 'mn': 'Мастер', 'ru': 'Мастер'},
|
||||
'신형': {'en': 'New', 'mn': 'Шинэ', 'ru': 'Новый'},
|
||||
'더 뉴': {'en': 'The New', 'mn': 'Шинэ', 'ru': 'Новый'},
|
||||
'그랜드스타렉스': {'en': 'Grand Starex', 'mn': 'Гранд Старекс', 'ru': 'Гранд Старекс'},
|
||||
'스타렉스': {'en': 'Starex', 'mn': 'Старекс', 'ru': 'Старекс'},
|
||||
'싼타페': {'en': 'Santa Fe', 'mn': 'Санта Фе', 'ru': 'Санта Фе'},
|
||||
'스팅어': {'en': 'Stinger', 'mn': 'Стингер', 'ru': 'Стингер'},
|
||||
'마이스터': {'en': 'Meister', 'mn': 'Мейстер', 'ru': 'Мейстер'},
|
||||
'쏘렌토': {'en': 'Sorento', 'mn': 'Соренто', 'ru': 'Соренто'},
|
||||
'스포티지': {'en': 'Sportage', 'mn': 'Спортаж', 'ru': 'Спортаж'},
|
||||
'카니발': {'en': 'Carnival', 'mn': 'Карнивал', 'ru': 'Карнивал'},
|
||||
'셀토스': {'en': 'Seltos', 'mn': 'Селтос', 'ru': 'Селтос'},
|
||||
'투싼': {'en': 'Tucson', 'mn': 'Туксон', 'ru': 'Туксон'},
|
||||
'팰리세이드': {'en': 'Palisade', 'mn': 'Палисейд', 'ru': 'Палисейд'},
|
||||
'아반떼': {'en': 'Avante', 'mn': 'Аванте', 'ru': 'Аванте'},
|
||||
'쏘나타': {'en': 'Sonata', 'mn': 'Соната', 'ru': 'Соната'},
|
||||
'그랜저': {'en': 'Grandeur', 'mn': 'Грандёр', 'ru': 'Грандёр'},
|
||||
'코나': {'en': 'Kona', 'mn': 'Кона', 'ru': 'Кона'},
|
||||
'K5': {'en': 'K5', 'mn': 'K5', 'ru': 'K5'},
|
||||
'K3': {'en': 'K3', 'mn': 'K3', 'ru': 'K3'},
|
||||
'K7': {'en': 'K7', 'mn': 'K7', 'ru': 'K7'},
|
||||
'K8': {'en': 'K8', 'mn': 'K8', 'ru': 'K8'},
|
||||
'K9': {'en': 'K9', 'mn': 'K9', 'ru': 'K9'},
|
||||
'GV70': {'en': 'GV70', 'mn': 'GV70', 'ru': 'GV70'},
|
||||
'GV80': {'en': 'GV80', 'mn': 'GV80', 'ru': 'GV80'},
|
||||
'G70': {'en': 'G70', 'mn': 'G70', 'ru': 'G70'},
|
||||
'G80': {'en': 'G80', 'mn': 'G80', 'ru': 'G80'},
|
||||
'G90': {'en': 'G90', 'mn': 'G90', 'ru': 'G90'},
|
||||
}
|
||||
|
||||
# Sort keys by length (longest first) to avoid partial matches
|
||||
SORTED_KEYS = sorted(TRANSLATIONS.keys(), key=len, reverse=True)
|
||||
|
||||
|
||||
def translate(text: str, lang: str) -> str:
|
||||
"""Translate Korean text to target language"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
result = text
|
||||
for key in SORTED_KEYS:
|
||||
if key in result:
|
||||
translation = TRANSLATIONS[key].get(lang, key)
|
||||
result = result.replace(key, translation)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
# Find database file
|
||||
db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "autonet.db")
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database not found at: {db_path}")
|
||||
return
|
||||
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all banners
|
||||
cursor.execute("SELECT id, title_ko, title_en, title_mn, title_ru FROM hero_banners")
|
||||
banners = cursor.fetchall()
|
||||
|
||||
print(f"\nFound {len(banners)} banners to update:\n")
|
||||
|
||||
for banner in banners:
|
||||
banner_id, title_ko, title_en, title_mn, title_ru = banner
|
||||
|
||||
if not title_ko:
|
||||
print(f"Banner {banner_id}: No Korean title, skipping")
|
||||
continue
|
||||
|
||||
new_title_en = translate(title_ko, 'en')
|
||||
new_title_mn = translate(title_ko, 'mn')
|
||||
new_title_ru = translate(title_ko, 'ru')
|
||||
|
||||
print(f"Banner {banner_id}:")
|
||||
print(f" KO: {title_ko}")
|
||||
print(f" EN: {title_en} -> {new_title_en}")
|
||||
print(f" MN: {title_mn} -> {new_title_mn}")
|
||||
print(f" RU: {title_ru} -> {new_title_ru}")
|
||||
print()
|
||||
|
||||
# Update the banner
|
||||
cursor.execute("""
|
||||
UPDATE hero_banners
|
||||
SET title_en = ?, title_mn = ?, title_ru = ?
|
||||
WHERE id = ?
|
||||
""", (new_title_en, new_title_mn, new_title_ru, banner_id))
|
||||
|
||||
conn.commit()
|
||||
print(f"Updated {len(banners)} banners successfully!")
|
||||
|
||||
# Verify
|
||||
print("\n--- Verification ---")
|
||||
cursor.execute("SELECT id, title_ko, title_en, title_mn FROM hero_banners")
|
||||
for row in cursor.fetchall():
|
||||
print(f"ID {row[0]}: {row[1]} -> EN: {row[2]}, MN: {row[3]}")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
297
backend/migrate_to_postgres.py
Normal file
297
backend/migrate_to_postgres.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
SQLite to PostgreSQL Migration Script
|
||||
Handles boolean conversion and foreign key constraints
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import sqlite3
|
||||
import psycopg2
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
SQLITE_PATH = os.path.join(os.path.dirname(__file__), "autonet.db")
|
||||
PG_CONFIG = {
|
||||
"host": "192.168.0.201",
|
||||
"port": 5432,
|
||||
"database": "autonet",
|
||||
"user": "admin",
|
||||
"password": "roskfl@1122"
|
||||
}
|
||||
|
||||
# Tables in dependency order (parents first)
|
||||
TABLES_ORDER = [
|
||||
# Base tables (no FK dependencies)
|
||||
"car_makers",
|
||||
"car_models",
|
||||
"translations",
|
||||
"system_settings",
|
||||
"cc_packages",
|
||||
"exchange_rates",
|
||||
"exchange_rate_history",
|
||||
"hero_banner_settings",
|
||||
# Users before user-dependent tables
|
||||
"users",
|
||||
# Car related
|
||||
"cars",
|
||||
"car_images",
|
||||
"car_options",
|
||||
"car_performance_checks",
|
||||
"car_specifications",
|
||||
"car_views",
|
||||
"performance_check_views",
|
||||
# Cache
|
||||
"car_cache",
|
||||
"car_detail_cache",
|
||||
"cache_request_queue",
|
||||
# Hero banners
|
||||
"hero_banners",
|
||||
# User activities
|
||||
"charge_history",
|
||||
"inquiries",
|
||||
"inquiry_messages",
|
||||
"vehicle_requests",
|
||||
"request_vehicles",
|
||||
"purchased_vehicles",
|
||||
"dealer_applications",
|
||||
"dealer_info",
|
||||
"vehicle_shares",
|
||||
"share_rewards",
|
||||
"withdrawal_requests",
|
||||
"referral_rewards",
|
||||
"notifications",
|
||||
"push_subscriptions",
|
||||
"user_notification_preferences",
|
||||
"verification_codes",
|
||||
"visitor_logs",
|
||||
"visitor_daily_stats",
|
||||
"visitor_sessions",
|
||||
]
|
||||
|
||||
def create_tables():
|
||||
"""Create tables in PostgreSQL"""
|
||||
print("\n[Step 1] Creating tables in PostgreSQL...")
|
||||
|
||||
os.environ["USE_SQLITE"] = "False"
|
||||
os.environ["DB_HOST"] = PG_CONFIG["host"]
|
||||
os.environ["DB_PORT"] = str(PG_CONFIG["port"])
|
||||
os.environ["DB_NAME"] = PG_CONFIG["database"]
|
||||
os.environ["DB_USER"] = PG_CONFIG["user"]
|
||||
os.environ["DB_PASSWORD"] = PG_CONFIG["password"]
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from app.database import Base
|
||||
from app.models import (
|
||||
CarMaker, CarModel, Car, CarImage, CarOption,
|
||||
User, CarView, PerformanceCheckView, ChargeHistory, VerificationCode,
|
||||
Inquiry, InquiryMessage, HeroBanner, HeroBannerSettings,
|
||||
Translation, CarCache, CarDetailCache, CacheRequestQueue,
|
||||
SystemSettings, VehicleRequest, RequestVehicle, PurchasedVehicle,
|
||||
DealerApplication, DealerInfo, VehicleShare, ShareReward,
|
||||
WithdrawalRequest, ReferralReward, Notification,
|
||||
PushSubscription, UserNotificationPreference,
|
||||
CarPerformanceCheck, CarSpecification,
|
||||
ExchangeRate, ExchangeRateHistory, CCPackage,
|
||||
VisitorLog, VisitorDailyStats, VisitorSession,
|
||||
)
|
||||
|
||||
encoded_pw = quote_plus(PG_CONFIG['password'])
|
||||
pg_url = f"postgresql://{PG_CONFIG['user']}:{encoded_pw}@{PG_CONFIG['host']}:{PG_CONFIG['port']}/{PG_CONFIG['database']}"
|
||||
engine = create_engine(pg_url, echo=False)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print(" Tables created successfully!")
|
||||
|
||||
def get_boolean_columns(pg_cursor, table_name):
|
||||
"""Get list of boolean columns for a table"""
|
||||
pg_cursor.execute("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = %s AND data_type = 'boolean'
|
||||
""", (table_name,))
|
||||
return [row[0] for row in pg_cursor.fetchall()]
|
||||
|
||||
def convert_row(row, columns, bool_cols):
|
||||
"""Convert SQLite row values for PostgreSQL (handle booleans)"""
|
||||
result = []
|
||||
for i, val in enumerate(row):
|
||||
col_name = columns[i]
|
||||
if col_name in bool_cols and val is not None:
|
||||
# Convert 0/1 to False/True
|
||||
result.append(bool(val))
|
||||
else:
|
||||
result.append(val)
|
||||
return tuple(result)
|
||||
|
||||
def migrate_table(sqlite_conn, pg_conn, table_name):
|
||||
"""Migrate a single table"""
|
||||
sqlite_cursor = sqlite_conn.cursor()
|
||||
pg_cursor = pg_conn.cursor()
|
||||
|
||||
# Get SQLite columns
|
||||
sqlite_cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
sqlite_cols = [col[1] for col in sqlite_cursor.fetchall()]
|
||||
if not sqlite_cols:
|
||||
return 0
|
||||
|
||||
# Get data
|
||||
sqlite_cursor.execute(f"SELECT * FROM {table_name}")
|
||||
rows = sqlite_cursor.fetchall()
|
||||
if not rows:
|
||||
print(f" {table_name}: 0 rows (empty)")
|
||||
return 0
|
||||
|
||||
# Check PostgreSQL table exists
|
||||
pg_cursor.execute("""
|
||||
SELECT EXISTS (SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s)
|
||||
""", (table_name,))
|
||||
if not pg_cursor.fetchone()[0]:
|
||||
print(f" {table_name}: skipped (not in PostgreSQL)")
|
||||
return 0
|
||||
|
||||
# Get PostgreSQL columns
|
||||
pg_cursor.execute("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
ORDER BY ordinal_position
|
||||
""", (table_name,))
|
||||
pg_cols = [row[0] for row in pg_cursor.fetchall()]
|
||||
|
||||
# Find common columns
|
||||
common_cols = [c for c in sqlite_cols if c in pg_cols]
|
||||
if not common_cols:
|
||||
print(f" {table_name}: skipped (no matching columns)")
|
||||
return 0
|
||||
|
||||
col_indices = [sqlite_cols.index(c) for c in common_cols]
|
||||
|
||||
# Get boolean columns
|
||||
bool_cols = set(get_boolean_columns(pg_cursor, table_name))
|
||||
|
||||
# Prepare query
|
||||
cols_str = ", ".join(common_cols)
|
||||
placeholders = ", ".join(["%s"] * len(common_cols))
|
||||
insert_sql = f"INSERT INTO {table_name} ({cols_str}) VALUES ({placeholders})"
|
||||
|
||||
try:
|
||||
# Truncate with CASCADE to handle FK
|
||||
pg_cursor.execute(f"TRUNCATE TABLE {table_name} CASCADE")
|
||||
|
||||
success = 0
|
||||
for row in rows:
|
||||
try:
|
||||
# Extract and convert row
|
||||
filtered = tuple(row[i] for i in col_indices)
|
||||
converted = convert_row(filtered, common_cols, bool_cols)
|
||||
pg_cursor.execute(insert_sql, converted)
|
||||
success += 1
|
||||
except Exception as e:
|
||||
pg_conn.rollback()
|
||||
# Truncate again after rollback
|
||||
pg_cursor.execute(f"TRUNCATE TABLE {table_name} CASCADE")
|
||||
print(f" {table_name}: error - {str(e)[:80]}")
|
||||
return 0
|
||||
|
||||
pg_conn.commit()
|
||||
print(f" {table_name}: {success}/{len(rows)} rows migrated")
|
||||
return success
|
||||
except Exception as e:
|
||||
pg_conn.rollback()
|
||||
print(f" {table_name}: error - {str(e)[:80]}")
|
||||
return 0
|
||||
|
||||
def reset_sequences(pg_conn):
|
||||
"""Reset sequences to max(id) + 1"""
|
||||
print("\n[Step 3] Resetting sequences...")
|
||||
pg_cursor = pg_conn.cursor()
|
||||
|
||||
# Get all tables with id column
|
||||
pg_cursor.execute("""
|
||||
SELECT table_name FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND column_name = 'id'
|
||||
""")
|
||||
tables = [row[0] for row in pg_cursor.fetchall()]
|
||||
|
||||
for table in tables:
|
||||
try:
|
||||
# Check if sequence exists
|
||||
seq_name = f"{table}_id_seq"
|
||||
pg_cursor.execute("""
|
||||
SELECT EXISTS (SELECT FROM pg_sequences WHERE schemaname = 'public' AND sequencename = %s)
|
||||
""", (seq_name,))
|
||||
if pg_cursor.fetchone()[0]:
|
||||
pg_cursor.execute(f"""
|
||||
SELECT setval('{seq_name}', COALESCE((SELECT MAX(id) FROM {table}), 0) + 1, false)
|
||||
""")
|
||||
except:
|
||||
pass
|
||||
|
||||
pg_conn.commit()
|
||||
print(" Sequences reset completed")
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("SQLite to PostgreSQL Migration")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Create tables
|
||||
try:
|
||||
create_tables()
|
||||
except Exception as e:
|
||||
print(f" Failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Connect and migrate
|
||||
print("\n[Step 2] Migrating data...")
|
||||
|
||||
sqlite_conn = sqlite3.connect(SQLITE_PATH)
|
||||
|
||||
encoded_pw = quote_plus(PG_CONFIG['password'])
|
||||
pg_conn = psycopg2.connect(
|
||||
host=PG_CONFIG['host'],
|
||||
port=PG_CONFIG['port'],
|
||||
database=PG_CONFIG['database'],
|
||||
user=PG_CONFIG['user'],
|
||||
password=PG_CONFIG['password']
|
||||
)
|
||||
|
||||
# Disable FK checks during migration
|
||||
pg_cursor = pg_conn.cursor()
|
||||
pg_cursor.execute("SET session_replication_role = 'replica';")
|
||||
pg_conn.commit()
|
||||
|
||||
# Get SQLite tables
|
||||
sqlite_cursor = sqlite_conn.cursor()
|
||||
sqlite_cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name != 'sqlite_sequence'")
|
||||
all_tables = set(t[0] for t in sqlite_cursor.fetchall())
|
||||
|
||||
# Migrate in order
|
||||
total = 0
|
||||
migrated_tables = set()
|
||||
|
||||
for table in TABLES_ORDER:
|
||||
if table in all_tables:
|
||||
total += migrate_table(sqlite_conn, pg_conn, table)
|
||||
migrated_tables.add(table)
|
||||
|
||||
# Migrate remaining tables
|
||||
remaining = all_tables - migrated_tables
|
||||
for table in remaining:
|
||||
total += migrate_table(sqlite_conn, pg_conn, table)
|
||||
|
||||
# Re-enable FK checks
|
||||
pg_cursor.execute("SET session_replication_role = 'origin';")
|
||||
pg_conn.commit()
|
||||
|
||||
# Step 3: Reset sequences
|
||||
reset_sequences(pg_conn)
|
||||
|
||||
sqlite_conn.close()
|
||||
pg_conn.close()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Migration completed! Total rows: {total}")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,7 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
# psycopg2-binary # Uncomment for PostgreSQL production
|
||||
psycopg2-binary # PostgreSQL production
|
||||
redis
|
||||
python-dotenv
|
||||
pydantic
|
||||
@@ -15,6 +15,8 @@ lxml
|
||||
alembic
|
||||
email-validator
|
||||
playwright # PDF capture for performance check reports
|
||||
img2pdf # Convert screenshots to PDF
|
||||
pillow # Image processing for PDF generation
|
||||
apscheduler # Scheduled tasks (exchange rate updates)
|
||||
stripe # Payment processing
|
||||
user-agents # Visitor tracking
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import api, { heroBannersApi, carmodooApi, vehicleRequestsApi } from '@/lib/api';
|
||||
import { translateCarName } from '@/lib/i18n';
|
||||
|
||||
interface CarmodooMaker {
|
||||
code: string;
|
||||
@@ -130,6 +131,8 @@ export default function CarsAdminPage() {
|
||||
const [selectedCar, setSelectedCar] = useState<LocalCar | null>(null);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const [selectedLocalCars, setSelectedLocalCars] = useState<Set<number>>(new Set());
|
||||
const [registeringLocalBanner, setRegisteringLocalBanner] = useState(false);
|
||||
|
||||
// All Cars (public view) state
|
||||
const [allCars, setAllCars] = useState<LocalCar[]>([]);
|
||||
@@ -549,30 +552,6 @@ export default function CarsAdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 차량명 번역 함수
|
||||
const translateCarName = (koreanName: string | undefined): string => {
|
||||
if (!koreanName) return '-';
|
||||
|
||||
const translations: Record<string, string> = {
|
||||
'현대': 'Hyundai', '제네시스': 'Genesis', '기아': 'Kia',
|
||||
'쉐보레(대우)': 'Chevrolet', '쉐보레': 'Chevrolet',
|
||||
'르노(삼성)': 'Renault', 'KG모빌리티(쌍용)': 'KG Mobility',
|
||||
'닛산': 'Nissan', '렉서스': 'Lexus', '토요타': 'Toyota', '혼다': 'Honda',
|
||||
'쏘렌토': 'Sorento', '스포티지': 'Sportage', '셀토스': 'Seltos',
|
||||
'카니발': 'Carnival', '모닝': 'Morning', '레이': 'Ray',
|
||||
'아반떼': 'Avante', '쏘나타': 'Sonata', '그랜저': 'Grandeur',
|
||||
'투싼': 'Tucson', '싼타페': 'Santa Fe', '팰리세이드': 'Palisade',
|
||||
'코나': 'Kona', '스타리아': 'Staria', '캐스퍼': 'Casper',
|
||||
};
|
||||
|
||||
let result = koreanName;
|
||||
const sortedKeys = Object.keys(translations).sort((a, b) => b.length - a.length);
|
||||
for (const korean of sortedKeys) {
|
||||
result = result.replace(new RegExp(korean, 'g'), translations[korean]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 딜러 설명 미리보기 및 편집 함수
|
||||
const handleEditDealerDescription = async (car: CarmodooCarItem) => {
|
||||
setEditingCar(car);
|
||||
@@ -700,11 +679,13 @@ export default function CarsAdminPage() {
|
||||
|
||||
const bannerData = {
|
||||
title_ko: car.car_name || '',
|
||||
title_en: translateCarName(car.car_name),
|
||||
title_mn: translateCarName(car.car_name),
|
||||
title_en: translateCarName(car.car_name, 'en'),
|
||||
title_mn: translateCarName(car.car_name, 'mn'),
|
||||
title_ru: translateCarName(car.car_name, 'ru'),
|
||||
subtitle_ko: `${car.year}년식 | ${car.mileage?.toLocaleString()}km`,
|
||||
subtitle_en: `${car.year} | ${car.mileage?.toLocaleString()}km`,
|
||||
subtitle_mn: `${car.year} | ${car.mileage?.toLocaleString()}km`,
|
||||
subtitle_ru: `${car.year} | ${car.mileage?.toLocaleString()}km`,
|
||||
image_url: localImageUrl,
|
||||
link_url: `/cars/${carId}`,
|
||||
is_active: true,
|
||||
@@ -752,6 +733,80 @@ export default function CarsAdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Local Cars에서 배너 등록하는 함수
|
||||
const handleRegisterLocalCarAsBanner = async () => {
|
||||
if (selectedLocalCars.size === 0) {
|
||||
alert('Please select at least one car to register as banner.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`${selectedLocalCars.size}개의 차량을 Hero Banner로 등록하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRegisteringLocalBanner(true);
|
||||
try {
|
||||
const selectedCarsList = localCars.filter(car => selectedLocalCars.has(car.id));
|
||||
const existingBanners = await heroBannersApi.adminGetList();
|
||||
let orderStart = existingBanners.length;
|
||||
let successCount = 0;
|
||||
|
||||
for (const car of selectedCarsList) {
|
||||
const localImageUrl = `/uploads/cars/${car.id}/image_0.jpg`;
|
||||
|
||||
const bannerData = {
|
||||
title_ko: car.car_name || '',
|
||||
title_en: translateCarName(car.car_name || '', 'en'),
|
||||
title_mn: translateCarName(car.car_name || '', 'mn'),
|
||||
title_ru: translateCarName(car.car_name || '', 'ru'),
|
||||
subtitle_ko: `${car.year || ''}년식 | ${car.mileage ? formatMileage(car.mileage) : ''}`,
|
||||
subtitle_en: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`,
|
||||
subtitle_mn: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`,
|
||||
subtitle_ru: `${car.year || ''} | ${car.mileage ? formatMileage(car.mileage) : ''}`,
|
||||
image_url: localImageUrl,
|
||||
link_url: `/cars/${car.id}`,
|
||||
display_order: orderStart++,
|
||||
is_active: true,
|
||||
car_id: car.id,
|
||||
};
|
||||
|
||||
await heroBannersApi.adminCreate(bannerData);
|
||||
successCount++;
|
||||
}
|
||||
|
||||
alert(`${successCount}개의 차량이 Hero Banner로 등록되었습니다.`);
|
||||
setSelectedLocalCars(new Set());
|
||||
} catch (err) {
|
||||
console.error('Local banner registration failed:', err);
|
||||
alert('배너 등록에 실패했습니다.');
|
||||
} finally {
|
||||
setRegisteringLocalBanner(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Local car selection toggle
|
||||
const handleLocalCarSelect = (carId: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedLocalCars(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(carId)) {
|
||||
newSet.delete(carId);
|
||||
} else {
|
||||
newSet.add(carId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Select all local cars
|
||||
const handleSelectAllLocalCars = () => {
|
||||
if (selectedLocalCars.size === localCars.length) {
|
||||
setSelectedLocalCars(new Set());
|
||||
} else {
|
||||
setSelectedLocalCars(new Set(localCars.map(car => car.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 차량 추천 목록에 추가 함수 (Vehicle Request용)
|
||||
const handleAddToRequest = async () => {
|
||||
if (!requestId) return;
|
||||
@@ -1029,7 +1084,34 @@ export default function CarsAdminPage() {
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
Imported Cars ({localTotal} total)
|
||||
{selectedLocalCars.size > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-purple-600">
|
||||
({selectedLocalCars.size} selected)
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedLocalCars.size > 0 && (
|
||||
<button
|
||||
onClick={handleRegisterLocalCarAsBanner}
|
||||
disabled={registeringLocalBanner}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{registeringLocalBanner ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Registering...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Register as Banner</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => loadLocalCars(localPage)}
|
||||
disabled={localLoading}
|
||||
@@ -1041,6 +1123,7 @@ export default function CarsAdminPage() {
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
@@ -1066,6 +1149,14 @@ export default function CarsAdminPage() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedLocalCars.size === localCars.length && localCars.length > 0}
|
||||
onChange={handleSelectAllLocalCars}
|
||||
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="py-3 px-2 text-center text-sm font-medium text-gray-600">Display</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Image</th>
|
||||
<th className="py-3 px-4 text-left text-sm font-medium text-gray-600">Car Name</th>
|
||||
@@ -1088,6 +1179,24 @@ export default function CarsAdminPage() {
|
||||
className={`border-b border-gray-100 hover:bg-gray-50 cursor-pointer ${!car.is_displayed ? 'opacity-60' : ''}`}
|
||||
onClick={() => handleCarClick(car)}
|
||||
>
|
||||
<td className="py-3 px-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedLocalCars.has(car.id)}
|
||||
onChange={() => {
|
||||
setSelectedLocalCars(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(car.id)) {
|
||||
newSet.delete(car.id);
|
||||
} else {
|
||||
newSet.add(car.id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}}
|
||||
className="w-4 h-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 px-2 text-center">
|
||||
<button
|
||||
onClick={(e) => handleToggleDisplay(car, e)}
|
||||
@@ -2276,7 +2385,7 @@ export default function CarsAdminPage() {
|
||||
<textarea
|
||||
value={editCommentData.ko}
|
||||
onChange={(e) => setEditCommentData({ ...editCommentData, ko: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -2285,7 +2394,7 @@ export default function CarsAdminPage() {
|
||||
<textarea
|
||||
value={editCommentData.en}
|
||||
onChange={(e) => setEditCommentData({ ...editCommentData, en: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -2294,7 +2403,7 @@ export default function CarsAdminPage() {
|
||||
<textarea
|
||||
value={editCommentData.mn}
|
||||
onChange={(e) => setEditCommentData({ ...editCommentData, mn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -2303,7 +2412,7 @@ export default function CarsAdminPage() {
|
||||
<textarea
|
||||
value={editCommentData.ru}
|
||||
onChange={(e) => setEditCommentData({ ...editCommentData, ru: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-none"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm resize-y min-h-[80px]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ export const inquiriesApi = {
|
||||
// Hero Banners API
|
||||
export const heroBannersApi = {
|
||||
// Public APIs
|
||||
getList: async (lang: string = 'ko'): Promise<HeroBanner[]> => {
|
||||
getList: async (lang: string = 'en'): Promise<HeroBanner[]> => {
|
||||
const { data } = await api.get('/hero-banners/', { params: { lang } });
|
||||
return data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user