feat: Add promo preference survey on main page

- Add promo preference fields to User model (promo_preferred_maker,
  promo_preferred_model, promo_email_enabled)
- Create API endpoints for getting/updating promo preferences
- Create PromoPreference component with maker/model selection
- Show login prompt for non-logged-in users when interacting
- Add promo notification service to send emails when matching vehicles
  are added to promotion
- Add multi-language translations (en, mn, ru, ko)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2026-01-12 23:37:31 +09:00
parent 2378392f95
commit 2720689515
10 changed files with 618 additions and 11 deletions

View File

@@ -10,6 +10,7 @@ from ..database import get_db
from ..models import User, SystemSettings, ReferralReward
from ..models.user import generate_referral_code
from ..schemas import UserCreate, UserUpdate, UserResponse, Token
from ..schemas.user import PromoPreferenceUpdate, PromoPreferenceResponse
from ..config import get_settings
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -277,6 +278,28 @@ def update_me(
return current_user
@router.get("/me/promo-preference", response_model=PromoPreferenceResponse)
def get_promo_preference(current_user: User = Depends(get_current_user)):
"""프로모션 선호 차종 조회"""
return current_user
@router.put("/me/promo-preference", response_model=PromoPreferenceResponse)
def update_promo_preference(
promo_update: PromoPreferenceUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""프로모션 선호 차종 설정"""
current_user.promo_preferred_maker = promo_update.promo_preferred_maker
current_user.promo_preferred_model = promo_update.promo_preferred_model
current_user.promo_email_enabled = promo_update.promo_email_enabled
db.commit()
db.refresh(current_user)
return current_user
# Admin User Management Endpoints
@router.get("/admin/users")
def admin_get_users(

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Body
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Body, BackgroundTasks
from sqlalchemy.orm import Session
from typing import List, Optional
import os
@@ -17,6 +17,7 @@ from ..schemas.hero_banner import (
from .auth import get_current_user
from ..models import User
from ..config import get_settings
from ..services.promo_notification_service import notify_users_for_promo_vehicle
router = APIRouter(prefix="/hero-banners", tags=["hero-banners"])
@@ -309,15 +310,16 @@ async def upload_banner_image(
# ==================== Banner Toggle & Ordering ====================
@router.post("/admin/toggle/{car_id}")
def toggle_banner(
async def toggle_banner(
car_id: int,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""차량의 배너 상태 토글 (Admin)
- HeroBanner 존재 → 삭제
- HeroBanner 없음 → 생성
- HeroBanner 없음 → 생성 (+ 프로모션 알림 이메일 전송)
"""
car = db.query(Car).filter(Car.id == car_id).first()
if not car:
@@ -360,4 +362,8 @@ def toggle_banner(
car.is_banner = True
db.commit()
db.refresh(banner)
# Send promo notification emails in background
background_tasks.add_task(notify_users_for_promo_vehicle, db, car)
return {"car_id": car_id, "is_banner": True, "banner_id": banner.id, "message": "Added to banner"}

View File

@@ -37,6 +37,11 @@ class User(Base):
phone_verified = Column(Boolean, default=False)
phone_verified_at = Column(DateTime(timezone=True), nullable=True)
# Promotion preference (프로모션 선호 차종)
promo_preferred_maker = Column(String(100), nullable=True) # 선호 메이커명
promo_preferred_model = Column(String(100), nullable=True) # 선호 모델명
promo_email_enabled = Column(Boolean, default=False) # 프로모션 이메일 수신 동의
# Account withdrawal/deletion
withdrawal_requested_at = Column(DateTime(timezone=True), nullable=True) # 탈퇴 요청 시각
withdrawal_reason = Column(String(500), nullable=True) # 탈퇴 사유

View File

@@ -42,12 +42,31 @@ class UserResponse(BaseModel):
referral_code: Optional[str] = None # User's unique referral code
email_verified: bool = False
phone_verified: bool = False
promo_preferred_maker: Optional[str] = None
promo_preferred_model: Optional[str] = None
promo_email_enabled: bool = False
created_at: datetime
class Config:
from_attributes = True
class PromoPreferenceUpdate(BaseModel):
"""Schema for updating promo preference"""
promo_preferred_maker: Optional[str] = None
promo_preferred_model: Optional[str] = None
promo_email_enabled: bool = False
class PromoPreferenceResponse(BaseModel):
promo_preferred_maker: Optional[str] = None
promo_preferred_model: Optional[str] = None
promo_email_enabled: bool = False
class Config:
from_attributes = True
class CarViewResponse(BaseModel):
id: int
user_id: int

View File

@@ -0,0 +1,254 @@
"""
Promo Notification Service
Sends email notifications to users when their preferred vehicle is added to a promotion
"""
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import List, Optional
from sqlalchemy.orm import Session
import asyncio
from ..config import get_settings
from ..models.user import User
from ..models.car import Car
settings = get_settings()
def get_users_with_matching_preference(
db: Session,
maker_name: Optional[str] = None,
model_name: Optional[str] = None
) -> List[User]:
"""
Find users whose promo preference matches the given maker/model
Only returns users who have promo_email_enabled = True
"""
query = db.query(User).filter(
User.promo_email_enabled == True,
User.email.isnot(None)
)
if maker_name:
# Match maker only (for users who selected maker but any model)
# or match exact maker+model
query = query.filter(User.promo_preferred_maker == maker_name)
if model_name:
# If model is specified, also filter by model (or users with no model preference)
query = query.filter(
(User.promo_preferred_model == model_name) |
(User.promo_preferred_model.is_(None)) |
(User.promo_preferred_model == "")
)
return query.all()
async def send_promo_notification_email(
email: str,
car: Car,
language: str = "en"
) -> bool:
"""Send promo notification email to a single user"""
try:
# Email templates by language
subjects = {
"en": f"AutonetSellCar - Your Preferred Vehicle is Now on Promotion!",
"ko": f"AutonetSellCar - 관심 차량이 프로모션에 등록되었습니다!",
"mn": f"AutonetSellCar - Таны сонирхсон машин урамшуулалд орлоо!",
"ru": f"AutonetSellCar - Ваш желаемый автомобиль теперь в акции!"
}
car_name = car.car_name or "Unknown Vehicle"
car_year = car.year or ""
car_mileage = f"{car.mileage:,}km" if car.mileage else ""
car_url = f"https://autonetsellcar.com/cars/{car.id}"
bodies = {
"en": f"""
Hello,
Great news! A vehicle matching your preference is now available on promotion!
Vehicle: {car_name}
Year: {car_year}
Mileage: {car_mileage}
View this vehicle: {car_url}
As a promoted vehicle, you can view all photos for free!
Best regards,
AutonetSellCar Team
---
You received this email because you signed up for promotion notifications.
To unsubscribe, update your preferences in your account settings.
""",
"ko": f"""
안녕하세요,
좋은 소식입니다! 관심 차량이 프로모션에 등록되었습니다!
차량: {car_name}
연식: {car_year}
주행거리: {car_mileage}
차량 보기: {car_url}
프로모션 차량은 모든 사진을 무료로 보실 수 있습니다!
감사합니다,
AutonetSellCar 팀
---
프로모션 알림 신청으로 이 이메일을 받으셨습니다.
수신 거부를 원하시면 계정 설정에서 변경해 주세요.
""",
"mn": f"""
Сайн байна уу,
Сайхан мэдээ! Таны сонирхсон машин урамшуулалд орлоо!
Машин: {car_name}
Он: {car_year}
Гүйлт: {car_mileage}
Машин үзэх: {car_url}
Урамшуулалтай машины бүх зургийг үнэгүй үзэх боломжтой!
Хүндэтгэсэн,
AutonetSellCar баг
---
Та урамшуулалын мэдэгдэл хүлээн авахаар бүртгүүлсэн тул энэ имэйлийг хүлээн авлаа.
Татгалзахыг хүсвэл данс тохиргооноос өөрчилнө үү.
""",
"ru": f"""
Здравствуйте,
Отличные новости! Автомобиль, соответствующий вашим предпочтениям, теперь в акции!
Автомобиль: {car_name}
Год: {car_year}
Пробег: {car_mileage}
Посмотреть автомобиль: {car_url}
Как акционный автомобиль, вы можете просмотреть все фотографии бесплатно!
С уважением,
Команда AutonetSellCar
---
Вы получили это письмо, потому что подписались на уведомления об акциях.
Чтобы отписаться, измените настройки в вашем аккаунте.
"""
}
subject = subjects.get(language, subjects["en"])
body = bodies.get(language, bodies["en"])
# Check if SMTP is configured
if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
# Development mode - just log
print(f"[DEV] Promo notification email for {email}: {car_name}")
return True
# Send actual email
msg = MIMEMultipart()
msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL or settings.SMTP_USER}>"
msg['To'] = email
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain', 'utf-8'))
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
server.starttls()
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
server.send_message(msg)
print(f"[INFO] Promo notification sent to {email} for car {car.id}")
return True
except Exception as e:
print(f"[ERROR] Failed to send promo notification to {email}: {e}")
return False
async def notify_users_for_promo_vehicle(
db: Session,
car: Car
) -> int:
"""
Notify all users whose preference matches the car being promoted
Returns the number of notifications sent
"""
# Get maker and model names from car
maker_name = car.maker.name if car.maker else None
model_name = car.model.name if car.model else None
if not maker_name:
print(f"[INFO] Car {car.id} has no maker, skipping promo notifications")
return 0
# Find matching users
matching_users = get_users_with_matching_preference(
db=db,
maker_name=maker_name,
model_name=model_name
)
if not matching_users:
print(f"[INFO] No users with matching preference for {maker_name} {model_name}")
return 0
print(f"[INFO] Found {len(matching_users)} users with preference for {maker_name} {model_name}")
# Send notifications
sent_count = 0
for user in matching_users:
if user.email:
# Determine language based on user's country
language = "en" # Default
if user.country:
country_lower = user.country.lower()
if country_lower in ["korea", "south korea", "kr", "한국"]:
language = "ko"
elif country_lower in ["mongolia", "mn", "монгол"]:
language = "mn"
elif country_lower in ["russia", "ru", "россия"]:
language = "ru"
success = await send_promo_notification_email(
email=user.email,
car=car,
language=language
)
if success:
sent_count += 1
return sent_count
def notify_users_for_promo_vehicle_sync(db: Session, car: Car) -> int:
"""Synchronous wrapper for notify_users_for_promo_vehicle"""
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if loop.is_running():
# If already in async context, create a task
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(
asyncio.run,
notify_users_for_promo_vehicle(db, car)
)
return future.result()
else:
return loop.run_until_complete(notify_users_for_promo_vehicle(db, car))