Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API Routes
|
||||
546
backend/app/api/auth.py
Normal file
546
backend/app/api/auth.py
Normal file
@@ -0,0 +1,546 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import bcrypt
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..models.user import generate_referral_code
|
||||
from ..schemas import UserCreate, UserUpdate, UserResponse, Token
|
||||
from ..config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
settings = get_settings()
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
# Optional 인증 scheme
|
||||
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def get_current_user_optional(
|
||||
token: str = Depends(oauth2_scheme_optional),
|
||||
db: Session = Depends(get_db)
|
||||
) -> User | None:
|
||||
"""선택적 인증 - 토큰이 없거나 유효하지 않아도 None 반환"""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
return None
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
return user
|
||||
|
||||
|
||||
def get_current_admin_user(
|
||||
current_user: User = Depends(get_current_user)
|
||||
) -> User:
|
||||
"""관리자 권한 확인"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
"""회원가입"""
|
||||
from ..models.user import VerificationCode
|
||||
from datetime import datetime
|
||||
|
||||
existing = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# Check if email was verified (pre-registration verification)
|
||||
email_verified = False
|
||||
verification = db.query(VerificationCode).filter(
|
||||
VerificationCode.email == user_data.email,
|
||||
VerificationCode.code_type == "email",
|
||||
VerificationCode.verified_at.isnot(None)
|
||||
).order_by(VerificationCode.verified_at.desc()).first()
|
||||
|
||||
if verification:
|
||||
email_verified = True
|
||||
|
||||
# Generate unique referral code
|
||||
referral_code = generate_referral_code()
|
||||
while db.query(User).filter(User.referral_code == referral_code).first():
|
||||
referral_code = generate_referral_code()
|
||||
|
||||
user = User(
|
||||
email=user_data.email,
|
||||
password_hash=get_password_hash(user_data.password),
|
||||
name=user_data.name,
|
||||
phone=user_data.phone,
|
||||
country=user_data.country,
|
||||
referral_code=referral_code,
|
||||
referred_by=getattr(user_data, 'referred_by', None),
|
||||
email_verified=email_verified,
|
||||
email_verified_at=datetime.utcnow() if email_verified else None,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""로그인"""
|
||||
user = db.query(User).filter(User.email == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
return Token(access_token=access_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""현재 사용자 정보"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
def update_me(
|
||||
user_update: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""사용자 정보 수정"""
|
||||
if user_update.name is not None:
|
||||
current_user.name = user_update.name
|
||||
if user_update.phone is not None:
|
||||
current_user.phone = user_update.phone
|
||||
if user_update.country is not None:
|
||||
current_user.country = user_update.country
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
# Admin User Management Endpoints
|
||||
@router.get("/admin/users")
|
||||
def admin_get_users(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
search: str = None,
|
||||
is_dealer: bool = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""모든 사용자 목록 조회 (관리자) - 삭제된 사용자 제외"""
|
||||
query = db.query(User).filter(
|
||||
User.is_admin == False,
|
||||
User.deleted_at.is_(None) # 삭제되지 않은 사용자만
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
(User.email.ilike(f"%{search}%")) |
|
||||
(User.name.ilike(f"%{search}%")) |
|
||||
(User.phone.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
if is_dealer is not None:
|
||||
query = query.filter(User.is_dealer == is_dealer)
|
||||
|
||||
total = query.count()
|
||||
users = query.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_dealer": user.is_dealer,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by": user.referred_by,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/users/{user_id}")
|
||||
def admin_get_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""특정 사용자 상세 정보 (관리자)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_admin": user.is_admin,
|
||||
"is_dealer": user.is_dealer,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by": user.referred_by,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/admin/users/{user_id}")
|
||||
def admin_update_user(
|
||||
user_id: int,
|
||||
user_update: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""사용자 정보 수정 (관리자)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user_update.name is not None:
|
||||
user.name = user_update.name
|
||||
if user_update.phone is not None:
|
||||
user.phone = user_update.phone
|
||||
if user_update.country is not None:
|
||||
user.country = user_update.country
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_dealer": user.is_dealer,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/admin/users/{user_id}/cc")
|
||||
def admin_adjust_cc(
|
||||
user_id: int,
|
||||
amount: float,
|
||||
reason: str = "Admin adjustment",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""사용자 CC 잔액 조정 (관리자)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user.cc_balance = user.cc_balance + amount
|
||||
if user.cc_balance < 0:
|
||||
user.cc_balance = 0
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {
|
||||
"message": f"CC balance adjusted by {amount}",
|
||||
"new_balance": user.cc_balance
|
||||
}
|
||||
|
||||
|
||||
# ===== 사용자 탈퇴 =====
|
||||
|
||||
class WithdrawalRequest(BaseModel):
|
||||
"""탈퇴 요청"""
|
||||
reason: Optional[str] = None
|
||||
password: str # 본인 확인용
|
||||
|
||||
|
||||
@router.post("/withdraw")
|
||||
def request_withdrawal(
|
||||
request: WithdrawalRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""사용자 탈퇴 요청"""
|
||||
# 비밀번호 확인
|
||||
if not verify_password(request.password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Incorrect password")
|
||||
|
||||
# 이미 탈퇴 요청한 경우
|
||||
if current_user.withdrawal_requested_at:
|
||||
raise HTTPException(status_code=400, detail="Withdrawal already requested")
|
||||
|
||||
# 관리자는 탈퇴 불가
|
||||
if current_user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Admin cannot withdraw")
|
||||
|
||||
# 탈퇴 요청 기록
|
||||
current_user.withdrawal_requested_at = datetime.utcnow()
|
||||
current_user.withdrawal_reason = request.reason
|
||||
current_user.is_active = False # 계정 비활성화
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Withdrawal request submitted. Your account has been deactivated.",
|
||||
"withdrawal_requested_at": current_user.withdrawal_requested_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.post("/withdraw/cancel")
|
||||
def cancel_withdrawal(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""탈퇴 요청 취소 (아직 삭제되지 않은 경우)"""
|
||||
if not current_user.withdrawal_requested_at:
|
||||
raise HTTPException(status_code=400, detail="No withdrawal request found")
|
||||
|
||||
if current_user.deleted_at:
|
||||
raise HTTPException(status_code=400, detail="Account already deleted")
|
||||
|
||||
# 탈퇴 요청 취소
|
||||
current_user.withdrawal_requested_at = None
|
||||
current_user.withdrawal_reason = None
|
||||
current_user.is_active = True
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": "Withdrawal request cancelled. Your account is active again."}
|
||||
|
||||
|
||||
# ===== 관리자 사용자 삭제 =====
|
||||
|
||||
@router.delete("/admin/users/{user_id}")
|
||||
def admin_delete_user(
|
||||
user_id: int,
|
||||
hard_delete: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""사용자 삭제 (관리자 전용)
|
||||
- hard_delete=False: 소프트 삭제 (deleted_at 설정)
|
||||
- hard_delete=True: 완전 삭제 (DB에서 제거)
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 관리자는 삭제 불가
|
||||
if user.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete admin user")
|
||||
|
||||
# 자기 자신은 삭제 불가
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
user_email = user.email
|
||||
|
||||
if hard_delete:
|
||||
# 완전 삭제 - 관련 데이터도 함께 삭제
|
||||
from ..models import CarView, PerformanceCheckView, ChargeHistory, Inquiry, Notification
|
||||
|
||||
db.query(CarView).filter(CarView.user_id == user_id).delete()
|
||||
db.query(PerformanceCheckView).filter(PerformanceCheckView.user_id == user_id).delete()
|
||||
db.query(ChargeHistory).filter(ChargeHistory.user_id == user_id).delete()
|
||||
db.query(Inquiry).filter(Inquiry.user_id == user_id).delete()
|
||||
db.query(Notification).filter(Notification.user_id == user_id).delete()
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user_email} permanently deleted",
|
||||
"deleted_user_id": user_id,
|
||||
"hard_delete": True
|
||||
}
|
||||
else:
|
||||
# 소프트 삭제
|
||||
user.deleted_at = datetime.utcnow()
|
||||
user.is_active = False
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user_email} soft deleted",
|
||||
"deleted_user_id": user_id,
|
||||
"deleted_at": user.deleted_at.isoformat(),
|
||||
"hard_delete": False
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/users/withdrawn")
|
||||
def admin_get_withdrawn_users(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""탈퇴 요청한 사용자 목록 (관리자)"""
|
||||
query = db.query(User).filter(
|
||||
User.withdrawal_requested_at.isnot(None),
|
||||
User.deleted_at.is_(None) # 아직 삭제되지 않은 사용자만
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
users = query.order_by(User.withdrawal_requested_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_dealer": user.is_dealer,
|
||||
"withdrawal_requested_at": user.withdrawal_requested_at.isoformat() if user.withdrawal_requested_at else None,
|
||||
"withdrawal_reason": user.withdrawal_reason,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/users/deleted")
|
||||
def admin_get_deleted_users(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
search: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""삭제된 사용자 목록 (관리자)"""
|
||||
query = db.query(User).filter(
|
||||
User.deleted_at.isnot(None) # 삭제된 사용자만
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
(User.email.ilike(f"%{search}%")) |
|
||||
(User.name.ilike(f"%{search}%")) |
|
||||
(User.phone.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
users = query.order_by(User.deleted_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"phone": user.phone,
|
||||
"country": user.country,
|
||||
"cc_balance": user.cc_balance,
|
||||
"is_dealer": user.is_dealer,
|
||||
"referral_code": user.referral_code,
|
||||
"referred_by": user.referred_by,
|
||||
"deleted_at": user.deleted_at.isoformat() if user.deleted_at else None,
|
||||
"withdrawal_reason": user.withdrawal_reason,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/restore")
|
||||
def admin_restore_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""삭제된 사용자 복원 (관리자)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if not user.deleted_at:
|
||||
raise HTTPException(status_code=400, detail="User is not deleted")
|
||||
|
||||
user.deleted_at = None
|
||||
user.is_active = True
|
||||
user.withdrawal_requested_at = None
|
||||
user.withdrawal_reason = None
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"User {user.email} restored successfully",
|
||||
"user_id": user_id
|
||||
}
|
||||
2691
backend/app/api/carmodoo.py
Normal file
2691
backend/app/api/carmodoo.py
Normal file
File diff suppressed because it is too large
Load Diff
340
backend/app/api/cars.py
Normal file
340
backend/app/api/cars.py
Normal file
@@ -0,0 +1,340 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import Optional, List
|
||||
from ..database import get_db
|
||||
from ..models import Car, CarMaker, CarModel, CarImage, CarOption
|
||||
from ..schemas import (
|
||||
CarCreate, CarUpdate, CarResponse, CarListResponse,
|
||||
CarMakerCreate, CarMakerResponse,
|
||||
CarModelCreate, CarModelResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/cars", tags=["cars"])
|
||||
|
||||
|
||||
def car_to_response(car: Car) -> dict:
|
||||
"""Convert Car model to response dict with computed final prices"""
|
||||
return {
|
||||
"id": car.id,
|
||||
"source": car.source,
|
||||
"source_id": car.source_id,
|
||||
"car_name": car.car_name,
|
||||
"year": car.year,
|
||||
"month": car.month,
|
||||
"mileage": car.mileage,
|
||||
"price_krw": car.price_krw,
|
||||
"margin_krw": car.margin_krw or 0,
|
||||
"margin_mn": car.margin_mn or 0,
|
||||
"final_price_krw": (car.price_krw or 0) + (car.margin_krw or 0),
|
||||
"final_price_mn": (car.price_krw or 0) + (car.margin_mn or 0),
|
||||
"price_usd": car.price_usd,
|
||||
"is_displayed": car.is_displayed or False,
|
||||
"fuel": car.fuel,
|
||||
"transmission": car.transmission,
|
||||
"color": car.color,
|
||||
"displacement": car.displacement,
|
||||
"car_number": car.car_number,
|
||||
"seize_count": car.seize_count or 0,
|
||||
"collateral_count": car.collateral_count or 0,
|
||||
"check_num": car.check_num,
|
||||
"dealer_name": car.dealer_name,
|
||||
"dealer_description": car.dealer_description,
|
||||
"status": car.status,
|
||||
"created_at": car.created_at,
|
||||
"updated_at": car.updated_at,
|
||||
"maker": car.maker,
|
||||
"model": car.model,
|
||||
"images": car.images,
|
||||
"specification": car.specification,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=CarListResponse)
|
||||
def get_cars(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
maker_id: Optional[int] = None,
|
||||
model_id: Optional[int] = None,
|
||||
year_min: Optional[int] = None,
|
||||
year_max: Optional[int] = None,
|
||||
price_min: Optional[int] = None,
|
||||
price_max: Optional[int] = None,
|
||||
mileage_max: Optional[int] = None,
|
||||
fuel: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
is_displayed: Optional[bool] = None,
|
||||
admin: bool = Query(False, description="Admin mode - show all cars"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""차량 목록 조회"""
|
||||
# Base query for filtering (without eager loading for count)
|
||||
base_query = db.query(Car)
|
||||
|
||||
# For non-admin (user-facing), only show displayed cars
|
||||
if not admin:
|
||||
base_query = base_query.filter(Car.is_displayed == True)
|
||||
|
||||
# status 필터 (None이면 전체 조회)
|
||||
if status:
|
||||
base_query = base_query.filter(Car.status == status)
|
||||
|
||||
# is_displayed 필터 (admin mode에서만 의미있음)
|
||||
if is_displayed is not None and admin:
|
||||
base_query = base_query.filter(Car.is_displayed == is_displayed)
|
||||
|
||||
if maker_id:
|
||||
base_query = base_query.filter(Car.maker_id == maker_id)
|
||||
if model_id:
|
||||
base_query = base_query.filter(Car.model_id == model_id)
|
||||
if year_min:
|
||||
base_query = base_query.filter(Car.year >= year_min)
|
||||
if year_max:
|
||||
base_query = base_query.filter(Car.year <= year_max)
|
||||
if price_min:
|
||||
base_query = base_query.filter(Car.price_krw >= price_min)
|
||||
if price_max:
|
||||
base_query = base_query.filter(Car.price_krw <= price_max)
|
||||
if mileage_max:
|
||||
base_query = base_query.filter(Car.mileage <= mileage_max)
|
||||
if fuel:
|
||||
base_query = base_query.filter(Car.fuel == fuel)
|
||||
|
||||
total = base_query.count()
|
||||
|
||||
# Add eager loading for actual data fetch
|
||||
cars = base_query.options(
|
||||
joinedload(Car.maker),
|
||||
joinedload(Car.model),
|
||||
joinedload(Car.images)
|
||||
).order_by(Car.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
# Convert to response with computed fields
|
||||
cars_response = [car_to_response(car) for car in cars]
|
||||
|
||||
return CarListResponse(
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
cars=cars_response
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{car_id}", response_model=CarResponse)
|
||||
def get_car(car_id: int, admin: bool = Query(False), db: Session = Depends(get_db)):
|
||||
"""차량 상세 조회"""
|
||||
car = db.query(Car).options(
|
||||
joinedload(Car.maker),
|
||||
joinedload(Car.model),
|
||||
joinedload(Car.images),
|
||||
joinedload(Car.specification)
|
||||
).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
# Non-admin can only see displayed cars
|
||||
if not admin and not car.is_displayed:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
return car_to_response(car)
|
||||
|
||||
|
||||
@router.post("", response_model=CarResponse)
|
||||
def create_car(car_data: CarCreate, db: Session = Depends(get_db)):
|
||||
"""차량 등록 (Agent용)"""
|
||||
# Check if car already exists
|
||||
existing = db.query(Car).filter(
|
||||
Car.source == car_data.source,
|
||||
Car.source_id == car_data.source_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Car already exists")
|
||||
|
||||
# Get maker and model IDs
|
||||
maker_id = None
|
||||
model_id = None
|
||||
|
||||
if car_data.maker_code:
|
||||
maker = db.query(CarMaker).filter(CarMaker.code == car_data.maker_code).first()
|
||||
if maker:
|
||||
maker_id = maker.id
|
||||
|
||||
if car_data.model_code and maker_id:
|
||||
model = db.query(CarModel).filter(
|
||||
CarModel.code == car_data.model_code,
|
||||
CarModel.maker_id == maker_id
|
||||
).first()
|
||||
if model:
|
||||
model_id = model.id
|
||||
|
||||
# Create car
|
||||
car = Car(
|
||||
source=car_data.source,
|
||||
source_id=car_data.source_id,
|
||||
source_key=car_data.source_key,
|
||||
maker_id=maker_id,
|
||||
model_id=model_id,
|
||||
car_name=car_data.car_name,
|
||||
year=car_data.year,
|
||||
month=car_data.month,
|
||||
mileage=car_data.mileage,
|
||||
price_krw=car_data.price_krw,
|
||||
price_usd=car_data.price_usd,
|
||||
fuel=car_data.fuel,
|
||||
transmission=car_data.transmission,
|
||||
color=car_data.color,
|
||||
displacement=car_data.displacement,
|
||||
car_number=car_data.car_number,
|
||||
seize_count=car_data.seize_count,
|
||||
collateral_count=car_data.collateral_count,
|
||||
check_num=car_data.check_num,
|
||||
dealer_name=car_data.dealer_name,
|
||||
dealer_phone=car_data.dealer_phone,
|
||||
shop_name=car_data.shop_name,
|
||||
memo=car_data.memo,
|
||||
)
|
||||
db.add(car)
|
||||
db.flush()
|
||||
|
||||
# Add images
|
||||
for i, img in enumerate(car_data.images):
|
||||
car_image = CarImage(
|
||||
car_id=car.id,
|
||||
url=img.url,
|
||||
local_path=img.local_path,
|
||||
is_main=(i == 0),
|
||||
sort_order=i
|
||||
)
|
||||
db.add(car_image)
|
||||
|
||||
# Add options
|
||||
for opt in car_data.options:
|
||||
car_option = CarOption(car_id=car.id, option_name=opt)
|
||||
db.add(car_option)
|
||||
|
||||
db.commit()
|
||||
db.refresh(car)
|
||||
return car
|
||||
|
||||
|
||||
@router.put("/{car_id}", response_model=CarResponse)
|
||||
def update_car(car_id: int, car_data: CarUpdate, db: Session = Depends(get_db)):
|
||||
"""차량 정보 수정"""
|
||||
car = db.query(Car).options(
|
||||
joinedload(Car.maker),
|
||||
joinedload(Car.model),
|
||||
joinedload(Car.images),
|
||||
joinedload(Car.specification)
|
||||
).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
for key, value in car_data.dict(exclude_unset=True).items():
|
||||
setattr(car, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(car)
|
||||
return car_to_response(car)
|
||||
|
||||
|
||||
@router.delete("/{car_id}")
|
||||
def delete_car(car_id: int, db: Session = Depends(get_db)):
|
||||
"""차량 삭제 (관련 데이터 포함)"""
|
||||
print(f"[DELETE] Deleting car {car_id}")
|
||||
car = db.query(Car).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
print(f"[DELETE] Car {car_id} not found")
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
try:
|
||||
# 관련 테이블 데이터 삭제
|
||||
from ..models.car import CarImage, CarOption
|
||||
from ..models.performance_check import CarPerformanceCheck
|
||||
from ..models.car_specification import CarSpecification
|
||||
from ..models.hero_banner import HeroBanner
|
||||
from ..models.user import CarView, PerformanceCheckView
|
||||
from sqlalchemy import text
|
||||
|
||||
# 이미지 삭제
|
||||
img_count = db.query(CarImage).filter(CarImage.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {img_count} images")
|
||||
# 옵션 삭제
|
||||
opt_count = db.query(CarOption).filter(CarOption.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {opt_count} options")
|
||||
# 성능점검 삭제
|
||||
pc_count = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {pc_count} performance checks")
|
||||
# 사양 삭제
|
||||
spec_count = db.query(CarSpecification).filter(CarSpecification.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {spec_count} specifications")
|
||||
# 차량 조회 기록 삭제
|
||||
cv_count = db.query(CarView).filter(CarView.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {cv_count} car views")
|
||||
# 성능점검 조회 기록 삭제
|
||||
pcv_count = db.query(PerformanceCheckView).filter(PerformanceCheckView.car_id == car_id).delete(synchronize_session=False)
|
||||
print(f"[DELETE] Deleted {pcv_count} performance check views")
|
||||
# 문의 기록에서 car_id 제거 (raw SQL로 실행하여 모델 스키마 검증 방지)
|
||||
result = db.execute(text("UPDATE inquiries SET car_id = NULL WHERE car_id = :car_id"), {"car_id": car_id})
|
||||
inq_count = result.rowcount
|
||||
print(f"[DELETE] Unlinked {inq_count} inquiries")
|
||||
# 배너에서 car_id 제거 (배너는 삭제하지 않고 연결만 해제)
|
||||
banner_count = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).update({"car_id": None}, synchronize_session=False)
|
||||
print(f"[DELETE] Unlinked {banner_count} banners")
|
||||
|
||||
# 차량 삭제
|
||||
db.delete(car)
|
||||
db.commit()
|
||||
print(f"[DELETE] Car {car_id} deleted successfully")
|
||||
return {"message": "Car deleted"}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
import traceback
|
||||
error_trace = traceback.format_exc()
|
||||
print(f"[DELETE] Error deleting car {car_id}: {e}\n{error_trace}")
|
||||
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
||||
|
||||
|
||||
# Makers
|
||||
@router.get("/makers/", response_model=List[CarMakerResponse])
|
||||
def get_makers(db: Session = Depends(get_db)):
|
||||
"""제조사 목록 조회"""
|
||||
return db.query(CarMaker).all()
|
||||
|
||||
|
||||
@router.post("/makers/", response_model=CarMakerResponse)
|
||||
def create_maker(maker_data: CarMakerCreate, db: Session = Depends(get_db)):
|
||||
"""제조사 등록"""
|
||||
existing = db.query(CarMaker).filter(CarMaker.code == maker_data.code).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
maker = CarMaker(**maker_data.dict())
|
||||
db.add(maker)
|
||||
db.commit()
|
||||
db.refresh(maker)
|
||||
return maker
|
||||
|
||||
|
||||
# Models
|
||||
@router.get("/models/", response_model=List[CarModelResponse])
|
||||
def get_models(maker_id: Optional[int] = None, db: Session = Depends(get_db)):
|
||||
"""모델 목록 조회"""
|
||||
query = db.query(CarModel)
|
||||
if maker_id:
|
||||
query = query.filter(CarModel.maker_id == maker_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
@router.post("/models/", response_model=CarModelResponse)
|
||||
def create_model(model_data: CarModelCreate, db: Session = Depends(get_db)):
|
||||
"""모델 등록"""
|
||||
existing = db.query(CarModel).filter(
|
||||
CarModel.code == model_data.code,
|
||||
CarModel.maker_id == model_data.maker_id
|
||||
).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
model = CarModel(**model_data.dict())
|
||||
db.add(model)
|
||||
db.commit()
|
||||
db.refresh(model)
|
||||
return model
|
||||
886
backend/app/api/cc.py
Normal file
886
backend/app/api/cc.py
Normal file
@@ -0,0 +1,886 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import stripe
|
||||
import logging
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Car, CarView, PerformanceCheckView, ChargeHistory, CarPerformanceCheck, CCPackage, DEFAULT_CC_PACKAGES
|
||||
from ..models.settings import SystemSettings
|
||||
from ..models.user import PaymentSettings
|
||||
from ..schemas import UserResponse, CarViewResponse, PurchaseViewRequest
|
||||
from .auth import get_current_user, get_current_admin_user, get_current_user_optional
|
||||
from .referral import create_referral_reward
|
||||
from .carmodoo import CarmodooClient, capture_performance_check_pdf
|
||||
from .notification import notify_system
|
||||
from ..config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# Configure Stripe
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
router = APIRouter(prefix="/cc", tags=["cc"])
|
||||
|
||||
|
||||
class ChargeRequest(BaseModel):
|
||||
amount: int
|
||||
currency: str = "USD"
|
||||
payment_method: str = "card"
|
||||
transaction_id: Optional[str] = None # For crypto payments
|
||||
wallet_address: Optional[str] = None # User's wallet for refunds
|
||||
|
||||
|
||||
class USDCChargeRequest(BaseModel):
|
||||
amount_usdc: int
|
||||
transaction_hash: str
|
||||
wallet_address: str
|
||||
network: str = "Polygon"
|
||||
|
||||
|
||||
class ChargeHistoryResponse(BaseModel):
|
||||
id: int
|
||||
amount_usd: int
|
||||
cc_amount: int
|
||||
payment_method: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/balance")
|
||||
def get_cc_balance(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user's CC balance"""
|
||||
return {"cc_balance": current_user.cc_balance or 0}
|
||||
|
||||
|
||||
@router.get("/views", response_model=List[CarViewResponse])
|
||||
def get_purchased_views(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get list of cars the user has paid to view"""
|
||||
views = db.query(CarView).filter(CarView.user_id == current_user.id).all()
|
||||
return views
|
||||
|
||||
|
||||
@router.get("/views/car-ids")
|
||||
def get_purchased_car_ids(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get list of car IDs the user has paid to view (for quick lookup)"""
|
||||
views = db.query(CarView.car_id).filter(CarView.user_id == current_user.id).all()
|
||||
return {"car_ids": [v[0] for v in views]}
|
||||
|
||||
|
||||
@router.post("/purchase-view")
|
||||
def purchase_car_view(
|
||||
request: PurchaseViewRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Purchase access to view full car details (costs 1 CC)"""
|
||||
car_id = request.car_id
|
||||
|
||||
# Check if car exists
|
||||
car = db.query(Car).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
# Check if already purchased
|
||||
existing_view = db.query(CarView).filter(
|
||||
CarView.user_id == current_user.id,
|
||||
CarView.car_id == car_id
|
||||
).first()
|
||||
|
||||
if existing_view:
|
||||
return {"message": "Already purchased", "cc_balance": current_user.cc_balance}
|
||||
|
||||
# Check if user has enough CC
|
||||
if (current_user.cc_balance or 0) < 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Insufficient CC balance. You need 1 CC to view full car details."
|
||||
)
|
||||
|
||||
# Deduct CC and create view record
|
||||
current_user.cc_balance = (current_user.cc_balance or 0) - 1
|
||||
|
||||
car_view = CarView(
|
||||
user_id=current_user.id,
|
||||
car_id=car_id,
|
||||
cc_paid=1
|
||||
)
|
||||
db.add(car_view)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Purchase successful",
|
||||
"cc_balance": current_user.cc_balance,
|
||||
"car_id": car_id
|
||||
}
|
||||
|
||||
|
||||
@router.get("/check-view/{car_id}")
|
||||
def check_car_view(
|
||||
car_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if user has purchased view access for a specific car"""
|
||||
existing_view = db.query(CarView).filter(
|
||||
CarView.user_id == current_user.id,
|
||||
CarView.car_id == car_id
|
||||
).first()
|
||||
|
||||
return {
|
||||
"has_access": existing_view is not None,
|
||||
"cc_balance": current_user.cc_balance or 0
|
||||
}
|
||||
|
||||
|
||||
PERFORMANCE_CHECK_COST = 0.1 # 0.1 CC for performance check view
|
||||
|
||||
|
||||
@router.post("/purchase-performance-check")
|
||||
async def purchase_performance_check_view(
|
||||
request: PurchaseViewRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Purchase access to view performance check (costs 0.1 CC)"""
|
||||
car_id = request.car_id
|
||||
|
||||
# Check if car exists
|
||||
car = db.query(Car).filter(Car.id == car_id).first()
|
||||
if not car:
|
||||
raise HTTPException(status_code=404, detail="Car not found")
|
||||
|
||||
# Check if performance check record exists
|
||||
perf_check = db.query(CarPerformanceCheck).filter(
|
||||
CarPerformanceCheck.car_id == car_id
|
||||
).first()
|
||||
|
||||
# If no performance check record, try to fetch from Carmodoo
|
||||
if not perf_check:
|
||||
try:
|
||||
carmodoo_client = CarmodooClient()
|
||||
check_num = car.check_num or ""
|
||||
|
||||
# Try to get check_num if not available
|
||||
if not check_num:
|
||||
check_num = await carmodoo_client.get_car_check_num(car.source_id, car.source_key or "")
|
||||
|
||||
if check_num:
|
||||
# Fetch performance check data
|
||||
perf_result = await carmodoo_client.get_performance_check(car.source_id, car.source_key or "", check_num)
|
||||
|
||||
if perf_result.get("found") and perf_result.get("data"):
|
||||
perf_data = perf_result["data"]
|
||||
# Create CarPerformanceCheck record
|
||||
perf_check = CarPerformanceCheck(
|
||||
car_id=car.id,
|
||||
check_number=perf_data.get("check_number") or check_num,
|
||||
check_date=perf_data.get("check_date"),
|
||||
valid_until=perf_data.get("valid_until"),
|
||||
first_registration=perf_data.get("first_registration"),
|
||||
mileage=perf_data.get("mileage"),
|
||||
mileage_status=perf_data.get("mileage_status"),
|
||||
seize_count=perf_data.get("seize_count", 0),
|
||||
collateral_count=perf_data.get("collateral_count", 0),
|
||||
is_flood_damaged=perf_data.get("is_flood_damaged", False),
|
||||
is_fire_damaged=perf_data.get("is_fire_damaged", False),
|
||||
is_total_loss=perf_data.get("is_total_loss", False),
|
||||
engine_status=perf_data.get("engine_status"),
|
||||
transmission_status=perf_data.get("transmission_status"),
|
||||
power_delivery_status=perf_data.get("power_delivery_status"),
|
||||
raw_data=perf_data,
|
||||
raw_html=perf_result.get("raw_html", "")[:50000],
|
||||
)
|
||||
db.add(perf_check)
|
||||
db.flush()
|
||||
|
||||
# Capture PDF
|
||||
try:
|
||||
pdf_path = await capture_performance_check_pdf(perf_check.check_number, car.id)
|
||||
if pdf_path:
|
||||
perf_check.pdf_path = pdf_path
|
||||
except Exception as pdf_error:
|
||||
logger.warning(f"PDF capture failed: {pdf_error}")
|
||||
|
||||
db.commit()
|
||||
db.refresh(perf_check)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch performance check: {e}")
|
||||
|
||||
if not perf_check:
|
||||
raise HTTPException(status_code=404, detail="Performance check not available for this car")
|
||||
|
||||
# Check if already purchased
|
||||
existing_view = db.query(PerformanceCheckView).filter(
|
||||
PerformanceCheckView.user_id == current_user.id,
|
||||
PerformanceCheckView.car_id == car_id
|
||||
).first()
|
||||
|
||||
if existing_view:
|
||||
return {"message": "Already purchased", "cc_balance": current_user.cc_balance}
|
||||
|
||||
# Check if user has enough CC
|
||||
if (current_user.cc_balance or 0) < PERFORMANCE_CHECK_COST:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Insufficient CC balance. You need {PERFORMANCE_CHECK_COST} CC to view performance check."
|
||||
)
|
||||
|
||||
# Deduct CC and create view record
|
||||
current_user.cc_balance = (current_user.cc_balance or 0) - PERFORMANCE_CHECK_COST
|
||||
|
||||
perf_view = PerformanceCheckView(
|
||||
user_id=current_user.id,
|
||||
car_id=car_id,
|
||||
cc_paid=PERFORMANCE_CHECK_COST
|
||||
)
|
||||
db.add(perf_view)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Purchase successful",
|
||||
"cc_balance": current_user.cc_balance,
|
||||
"car_id": car_id
|
||||
}
|
||||
|
||||
|
||||
@router.get("/check-performance-check/{car_id}")
|
||||
def check_performance_check_view(
|
||||
car_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check if user has purchased performance check view for a specific car"""
|
||||
# Check if performance check exists for this car
|
||||
perf_check = db.query(CarPerformanceCheck).filter(
|
||||
CarPerformanceCheck.car_id == car_id
|
||||
).first()
|
||||
|
||||
# Check 1: Purchased performance check (0.1 CC)
|
||||
existing_perf_view = db.query(PerformanceCheckView).filter(
|
||||
PerformanceCheckView.user_id == current_user.id,
|
||||
PerformanceCheckView.car_id == car_id
|
||||
).first()
|
||||
|
||||
# Check 2: Purchased full car view (1 CC) -> performance check included free
|
||||
existing_car_view = db.query(CarView).filter(
|
||||
CarView.user_id == current_user.id,
|
||||
CarView.car_id == car_id
|
||||
).first()
|
||||
|
||||
has_access = (existing_perf_view is not None) or (existing_car_view is not None)
|
||||
|
||||
return {
|
||||
"has_access": has_access,
|
||||
"has_performance_check": perf_check is not None,
|
||||
"cc_balance": current_user.cc_balance or 0,
|
||||
"cost": PERFORMANCE_CHECK_COST,
|
||||
"included_in_car_view": existing_car_view is not None # True if already purchased car view
|
||||
}
|
||||
|
||||
|
||||
@router.get("/payment-info")
|
||||
def get_payment_info():
|
||||
"""Get payment information including USDC wallet address"""
|
||||
return {
|
||||
"usdc_wallet_address": PaymentSettings.USDC_WALLET_ADDRESS,
|
||||
"usdc_network": PaymentSettings.USDC_NETWORK,
|
||||
"min_charge_usd": PaymentSettings.MIN_CHARGE_USD,
|
||||
"max_charge_usd": PaymentSettings.MAX_CHARGE_USD,
|
||||
"supported_currencies": PaymentSettings.SUPPORTED_CURRENCIES,
|
||||
"supported_methods": PaymentSettings.SUPPORTED_METHODS,
|
||||
"rate": "1 USD = 1 CC",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/charge")
|
||||
def charge_cc(
|
||||
request: ChargeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a charge request (for card or bank transfer - requires admin verification)"""
|
||||
# Validate amount
|
||||
if request.amount < PaymentSettings.MIN_CHARGE_USD:
|
||||
raise HTTPException(status_code=400, detail=f"Minimum charge amount is ${PaymentSettings.MIN_CHARGE_USD}")
|
||||
|
||||
if request.amount > PaymentSettings.MAX_CHARGE_USD:
|
||||
raise HTTPException(status_code=400, detail=f"Maximum charge amount is ${PaymentSettings.MAX_CHARGE_USD}")
|
||||
|
||||
# Calculate CC amount (1 USD = 1 CC)
|
||||
cc_amount = request.amount
|
||||
|
||||
# Determine status based on payment method
|
||||
# Card payments would go through a payment gateway (not implemented yet)
|
||||
# USDC and bank transfers require manual verification
|
||||
status = "pending" if request.payment_method in ["usdc", "bank_transfer"] else "pending"
|
||||
|
||||
# Create charge history record
|
||||
charge_record = ChargeHistory(
|
||||
user_id=current_user.id,
|
||||
amount=request.amount,
|
||||
amount_usd=request.amount, # Backwards compatibility
|
||||
cc_amount=cc_amount,
|
||||
currency=request.currency,
|
||||
payment_method=request.payment_method,
|
||||
transaction_id=request.transaction_id,
|
||||
wallet_address=request.wallet_address,
|
||||
status=status
|
||||
)
|
||||
db.add(charge_record)
|
||||
db.commit()
|
||||
db.refresh(charge_record)
|
||||
|
||||
return {
|
||||
"message": "Charge request created" if status == "pending" else "Charge successful",
|
||||
"charge_id": charge_record.id,
|
||||
"amount": request.amount,
|
||||
"currency": request.currency,
|
||||
"cc_amount": cc_amount,
|
||||
"status": status,
|
||||
"payment_info": {
|
||||
"usdc_wallet": PaymentSettings.USDC_WALLET_ADDRESS if request.payment_method == "usdc" else None,
|
||||
"network": PaymentSettings.USDC_NETWORK if request.payment_method == "usdc" else None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/charge/usdc")
|
||||
def charge_cc_usdc(
|
||||
request: USDCChargeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create USDC charge request with transaction hash"""
|
||||
# Validate amount
|
||||
if request.amount_usdc < PaymentSettings.MIN_CHARGE_USD:
|
||||
raise HTTPException(status_code=400, detail=f"Minimum charge amount is {PaymentSettings.MIN_CHARGE_USD} USDC")
|
||||
|
||||
if request.amount_usdc > PaymentSettings.MAX_CHARGE_USD:
|
||||
raise HTTPException(status_code=400, detail=f"Maximum charge amount is {PaymentSettings.MAX_CHARGE_USD} USDC")
|
||||
|
||||
# Check for duplicate transaction
|
||||
existing = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.transaction_id == request.transaction_hash
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="This transaction has already been submitted")
|
||||
|
||||
# Create pending charge record
|
||||
charge_record = ChargeHistory(
|
||||
user_id=current_user.id,
|
||||
amount=request.amount_usdc,
|
||||
amount_usd=request.amount_usdc,
|
||||
cc_amount=request.amount_usdc,
|
||||
currency="USDC",
|
||||
payment_method="usdc",
|
||||
transaction_id=request.transaction_hash,
|
||||
wallet_address=request.wallet_address,
|
||||
status="pending"
|
||||
)
|
||||
db.add(charge_record)
|
||||
db.commit()
|
||||
db.refresh(charge_record)
|
||||
|
||||
return {
|
||||
"message": "USDC payment submitted for verification",
|
||||
"charge_id": charge_record.id,
|
||||
"amount_usdc": request.amount_usdc,
|
||||
"cc_amount": request.amount_usdc,
|
||||
"status": "pending",
|
||||
"transaction_hash": request.transaction_hash
|
||||
}
|
||||
|
||||
|
||||
@router.get("/charge-history")
|
||||
def get_charge_history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's charge history"""
|
||||
history = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.user_id == current_user.id
|
||||
).order_by(desc(ChargeHistory.created_at)).limit(50).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": h.id,
|
||||
"amount": h.amount or h.amount_usd,
|
||||
"amount_usd": h.amount_usd,
|
||||
"currency": h.currency or "USD",
|
||||
"cc_amount": h.cc_amount,
|
||||
"payment_method": h.payment_method,
|
||||
"transaction_id": h.transaction_id,
|
||||
"status": h.status,
|
||||
"created_at": h.created_at.isoformat() if h.created_at else None
|
||||
}
|
||||
for h in history
|
||||
]
|
||||
|
||||
|
||||
# Admin endpoints for payment verification
|
||||
@router.get("/admin/pending")
|
||||
def admin_get_pending_payments(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get all pending payment requests (Admin only)"""
|
||||
pending = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.status == "pending"
|
||||
).order_by(desc(ChargeHistory.created_at)).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": h.id,
|
||||
"user_id": h.user_id,
|
||||
"user_email": h.user.email if h.user else None,
|
||||
"user_name": h.user.name if h.user else None,
|
||||
"amount": h.amount or h.amount_usd,
|
||||
"currency": h.currency or "USD",
|
||||
"cc_amount": h.cc_amount,
|
||||
"payment_method": h.payment_method,
|
||||
"transaction_id": h.transaction_id,
|
||||
"wallet_address": h.wallet_address,
|
||||
"status": h.status,
|
||||
"created_at": h.created_at.isoformat() if h.created_at else None
|
||||
}
|
||||
for h in pending
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/all")
|
||||
def admin_get_all_payments(
|
||||
status: str = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get all payment records with optional status filter (Admin only)"""
|
||||
query = db.query(ChargeHistory)
|
||||
|
||||
if status:
|
||||
query = query.filter(ChargeHistory.status == status)
|
||||
|
||||
total = query.count()
|
||||
payments = query.order_by(desc(ChargeHistory.created_at)).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"payments": [
|
||||
{
|
||||
"id": h.id,
|
||||
"user_id": h.user_id,
|
||||
"user_email": h.user.email if h.user else None,
|
||||
"user_name": h.user.name if h.user else None,
|
||||
"amount": h.amount or h.amount_usd,
|
||||
"currency": h.currency or "USD",
|
||||
"cc_amount": h.cc_amount,
|
||||
"payment_method": h.payment_method,
|
||||
"transaction_id": h.transaction_id,
|
||||
"wallet_address": h.wallet_address,
|
||||
"admin_note": h.admin_note,
|
||||
"status": h.status,
|
||||
"verified_at": h.verified_at.isoformat() if h.verified_at else None,
|
||||
"created_at": h.created_at.isoformat() if h.created_at else None
|
||||
}
|
||||
for h in payments
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.put("/admin/{charge_id}/verify")
|
||||
def admin_verify_payment(
|
||||
charge_id: int,
|
||||
approved: bool,
|
||||
admin_note: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Verify and approve/reject a pending payment (Admin only)"""
|
||||
charge = db.query(ChargeHistory).filter(ChargeHistory.id == charge_id).first()
|
||||
if not charge:
|
||||
raise HTTPException(status_code=404, detail="Charge record not found")
|
||||
|
||||
if charge.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"Charge is already {charge.status}")
|
||||
|
||||
if approved:
|
||||
charge.status = "completed"
|
||||
charge.verified_at = datetime.utcnow()
|
||||
charge.verified_by = current_user.id
|
||||
charge.admin_note = admin_note
|
||||
|
||||
# Credit CC to user
|
||||
user = db.query(User).filter(User.id == charge.user_id).first()
|
||||
if user:
|
||||
user.cc_balance = (user.cc_balance or 0) + charge.cc_amount
|
||||
|
||||
# Trigger referral reward if applicable
|
||||
if user.referred_by:
|
||||
referrer = db.query(User).filter(
|
||||
User.referral_code == user.referred_by
|
||||
).first()
|
||||
if referrer:
|
||||
create_referral_reward(
|
||||
referrer_id=referrer.id,
|
||||
referred_user_id=user.id,
|
||||
payment_amount=charge.amount_usd or charge.amount,
|
||||
db=db
|
||||
)
|
||||
|
||||
# Send notification to user
|
||||
notify_system(
|
||||
db,
|
||||
user.id,
|
||||
"Payment Confirmed",
|
||||
f"Your payment of {charge.amount} {charge.currency or 'USD'} has been confirmed. {charge.cc_amount} CC has been added to your balance.",
|
||||
"/profile"
|
||||
)
|
||||
else:
|
||||
charge.status = "rejected"
|
||||
charge.verified_at = datetime.utcnow()
|
||||
charge.verified_by = current_user.id
|
||||
charge.admin_note = admin_note
|
||||
|
||||
# Send notification to user
|
||||
user = db.query(User).filter(User.id == charge.user_id).first()
|
||||
if user:
|
||||
notify_system(
|
||||
db,
|
||||
user.id,
|
||||
"Payment Rejected",
|
||||
f"Your payment request for {charge.amount} {charge.currency or 'USD'} was rejected. Reason: {admin_note or 'No reason provided'}",
|
||||
"/profile"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Payment {'approved' if approved else 'rejected'}",
|
||||
"charge_id": charge_id,
|
||||
"new_status": charge.status
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Stripe Payment Endpoints
|
||||
# ============================================
|
||||
|
||||
class CreateCheckoutRequest(BaseModel):
|
||||
package_id: int
|
||||
|
||||
|
||||
@router.get("/stripe-key")
|
||||
def get_stripe_publishable_key():
|
||||
"""Get Stripe publishable key for frontend"""
|
||||
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}
|
||||
|
||||
|
||||
@router.get("/packages")
|
||||
def get_cc_packages(db: Session = Depends(get_db)):
|
||||
"""Get available CC packages"""
|
||||
# Get system settings for cars_per_cc
|
||||
system_settings = db.query(SystemSettings).first()
|
||||
cars_per_cc = system_settings.cars_per_cc if system_settings and system_settings.cars_per_cc else 3
|
||||
|
||||
# First try to get from database
|
||||
packages = db.query(CCPackage).filter(CCPackage.is_active == True).order_by(CCPackage.sort_order).all()
|
||||
|
||||
# If no packages in DB, initialize with defaults
|
||||
if not packages:
|
||||
for pkg_data in DEFAULT_CC_PACKAGES:
|
||||
pkg = CCPackage(**pkg_data)
|
||||
db.add(pkg)
|
||||
db.commit()
|
||||
packages = db.query(CCPackage).filter(CCPackage.is_active == True).order_by(CCPackage.sort_order).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": pkg.id,
|
||||
"name": pkg.name,
|
||||
"price_usd": pkg.price_usd,
|
||||
"cc_amount": pkg.cc_amount,
|
||||
"bonus_cc": pkg.bonus_cc,
|
||||
"total_cc": pkg.cc_amount + pkg.bonus_cc,
|
||||
"discount_percent": pkg.discount_percent,
|
||||
"recommendations": (pkg.cc_amount + pkg.bonus_cc) * cars_per_cc,
|
||||
"cars_per_cc": cars_per_cc, # 프론트엔드에서 표시용
|
||||
}
|
||||
for pkg in packages
|
||||
]
|
||||
|
||||
|
||||
@router.post("/create-checkout-session")
|
||||
def create_checkout_session(
|
||||
request: CreateCheckoutRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create Stripe checkout session for CC purchase"""
|
||||
if not settings.STRIPE_SECRET_KEY:
|
||||
raise HTTPException(status_code=500, detail="Stripe is not configured")
|
||||
|
||||
# Get package
|
||||
package = db.query(CCPackage).filter(CCPackage.id == request.package_id).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
if not package.is_active:
|
||||
raise HTTPException(status_code=400, detail="This package is no longer available")
|
||||
|
||||
try:
|
||||
# Create Stripe Checkout Session
|
||||
checkout_session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card"],
|
||||
line_items=[
|
||||
{
|
||||
"price_data": {
|
||||
"currency": "usd",
|
||||
"unit_amount": package.price_usd * 100, # Stripe uses cents
|
||||
"product_data": {
|
||||
"name": f"AutonetSellCar CC - {package.name}",
|
||||
"description": f"{package.cc_amount + package.bonus_cc} CC ({package.cc_amount} + {package.bonus_cc} bonus)",
|
||||
},
|
||||
},
|
||||
"quantity": 1,
|
||||
}
|
||||
],
|
||||
mode="payment",
|
||||
success_url=f"{settings.STRIPE_SUCCESS_URL}?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=settings.STRIPE_CANCEL_URL,
|
||||
client_reference_id=str(current_user.id),
|
||||
metadata={
|
||||
"user_id": str(current_user.id),
|
||||
"package_id": str(package.id),
|
||||
"cc_amount": str(package.cc_amount),
|
||||
"bonus_cc": str(package.bonus_cc),
|
||||
},
|
||||
customer_email=current_user.email,
|
||||
)
|
||||
|
||||
# Create pending charge record
|
||||
charge_record = ChargeHistory(
|
||||
user_id=current_user.id,
|
||||
package_id=package.id,
|
||||
amount=package.price_usd,
|
||||
amount_usd=package.price_usd,
|
||||
cc_amount=package.cc_amount,
|
||||
bonus_cc=package.bonus_cc,
|
||||
currency="USD",
|
||||
payment_method="stripe",
|
||||
stripe_session_id=checkout_session.id,
|
||||
status="pending"
|
||||
)
|
||||
db.add(charge_record)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"checkout_url": checkout_session.url,
|
||||
"session_id": checkout_session.id
|
||||
}
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error(f"Stripe error: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Handle Stripe webhook events"""
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("stripe-signature")
|
||||
|
||||
if not settings.STRIPE_WEBHOOK_SECRET:
|
||||
logger.warning("Stripe webhook secret not configured")
|
||||
raise HTTPException(status_code=500, detail="Webhook not configured")
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid payload: {e}")
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
logger.error(f"Invalid signature: {e}")
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
|
||||
# Handle the checkout.session.completed event
|
||||
if event["type"] == "checkout.session.completed":
|
||||
session = event["data"]["object"]
|
||||
|
||||
# Get charge record by session ID
|
||||
charge = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.stripe_session_id == session["id"]
|
||||
).first()
|
||||
|
||||
if charge and charge.status == "pending":
|
||||
# Update charge record
|
||||
charge.status = "completed"
|
||||
charge.stripe_payment_intent_id = session.get("payment_intent")
|
||||
charge.verified_at = datetime.utcnow()
|
||||
|
||||
# Credit CC to user
|
||||
user = db.query(User).filter(User.id == charge.user_id).first()
|
||||
if user:
|
||||
total_cc = charge.cc_amount + (charge.bonus_cc or 0)
|
||||
user.cc_balance = (user.cc_balance or 0) + total_cc
|
||||
|
||||
# Trigger referral reward if applicable
|
||||
if user.referred_by:
|
||||
referrer = db.query(User).filter(
|
||||
User.referral_code == user.referred_by
|
||||
).first()
|
||||
if referrer:
|
||||
create_referral_reward(
|
||||
referrer_id=referrer.id,
|
||||
referred_user_id=user.id,
|
||||
payment_amount=charge.amount_usd or charge.amount,
|
||||
db=db
|
||||
)
|
||||
|
||||
# Send notification
|
||||
notify_system(
|
||||
db,
|
||||
user.id,
|
||||
"CC Purchase Successful",
|
||||
f"Your purchase of {total_cc} CC has been completed. Your new balance is {user.cc_balance} CC.",
|
||||
"/cc"
|
||||
)
|
||||
|
||||
logger.info(f"CC credited: user={user.id}, amount={total_cc}")
|
||||
|
||||
db.commit()
|
||||
|
||||
elif event["type"] == "checkout.session.expired":
|
||||
session = event["data"]["object"]
|
||||
|
||||
# Update charge record to cancelled
|
||||
charge = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.stripe_session_id == session["id"]
|
||||
).first()
|
||||
|
||||
if charge and charge.status == "pending":
|
||||
charge.status = "cancelled"
|
||||
db.commit()
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.get("/checkout-success")
|
||||
def checkout_success(
|
||||
session_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Verify checkout session and return result"""
|
||||
# Find charge record
|
||||
charge = db.query(ChargeHistory).filter(
|
||||
ChargeHistory.stripe_session_id == session_id,
|
||||
ChargeHistory.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not charge:
|
||||
raise HTTPException(status_code=404, detail="Payment record not found")
|
||||
|
||||
# If still pending, try to verify with Stripe
|
||||
if charge.status == "pending":
|
||||
try:
|
||||
session = stripe.checkout.Session.retrieve(session_id)
|
||||
if session.payment_status == "paid":
|
||||
charge.status = "completed"
|
||||
charge.stripe_payment_intent_id = session.payment_intent
|
||||
charge.verified_at = datetime.utcnow()
|
||||
|
||||
# Credit CC
|
||||
total_cc = charge.cc_amount + (charge.bonus_cc or 0)
|
||||
current_user.cc_balance = (current_user.cc_balance or 0) + total_cc
|
||||
|
||||
db.commit()
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error(f"Error verifying session: {e}")
|
||||
|
||||
return {
|
||||
"status": charge.status,
|
||||
"cc_amount": charge.cc_amount,
|
||||
"bonus_cc": charge.bonus_cc or 0,
|
||||
"total_cc": charge.cc_amount + (charge.bonus_cc or 0),
|
||||
"cc_balance": current_user.cc_balance or 0
|
||||
}
|
||||
|
||||
|
||||
# Manual CC charge request (for Russian users via Mongolian partner)
|
||||
class ManualChargeRequest(BaseModel):
|
||||
package_id: int
|
||||
payment_note: Optional[str] = None # e.g., "Paid via Mongolian partner bank"
|
||||
|
||||
|
||||
@router.post("/manual-request")
|
||||
def create_manual_charge_request(
|
||||
request: ManualChargeRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create manual CC charge request (for Russian users)"""
|
||||
# Get package
|
||||
package = db.query(CCPackage).filter(CCPackage.id == request.package_id).first()
|
||||
if not package:
|
||||
raise HTTPException(status_code=404, detail="Package not found")
|
||||
|
||||
# Create pending charge record
|
||||
charge_record = ChargeHistory(
|
||||
user_id=current_user.id,
|
||||
package_id=package.id,
|
||||
amount=package.price_usd,
|
||||
amount_usd=package.price_usd,
|
||||
cc_amount=package.cc_amount,
|
||||
bonus_cc=package.bonus_cc,
|
||||
currency="USD",
|
||||
payment_method="manual",
|
||||
admin_note=request.payment_note,
|
||||
status="pending"
|
||||
)
|
||||
db.add(charge_record)
|
||||
db.commit()
|
||||
db.refresh(charge_record)
|
||||
|
||||
# Notify admins
|
||||
admins = db.query(User).filter(User.is_admin == True).all()
|
||||
for admin in admins:
|
||||
notify_system(
|
||||
db,
|
||||
admin.id,
|
||||
"New Manual CC Request",
|
||||
f"User {current_user.email} requested {package.cc_amount} CC (${package.price_usd}). Payment method: manual.",
|
||||
"/admin/cc"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Manual charge request created. An admin will verify your payment.",
|
||||
"charge_id": charge_record.id,
|
||||
"package": {
|
||||
"name": package.name,
|
||||
"price_usd": package.price_usd,
|
||||
"cc_amount": package.cc_amount + package.bonus_cc
|
||||
},
|
||||
"status": "pending"
|
||||
}
|
||||
443
backend/app/api/dashboard.py
Normal file
443
backend/app/api/dashboard.py
Normal file
@@ -0,0 +1,443 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
User, Car, Inquiry, InquiryStatus,
|
||||
VehicleRequest, RequestVehicle, PurchasedVehicle,
|
||||
DealerApplication, DealerInfo,
|
||||
VehicleShare, ShareReward,
|
||||
WithdrawalRequest,
|
||||
ReferralReward,
|
||||
HeroBanner,
|
||||
ChargeHistory,
|
||||
)
|
||||
from .auth import get_current_admin_user
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
total_users: int
|
||||
new_users_today: int
|
||||
new_users_this_week: int
|
||||
total_dealers: int
|
||||
pending_dealer_applications: int
|
||||
total_cars: int
|
||||
total_vehicle_requests: int
|
||||
pending_requests: int
|
||||
total_purchased_vehicles: int
|
||||
total_inquiries: int
|
||||
pending_inquiries: int
|
||||
total_shares: int
|
||||
purchased_shares: int
|
||||
total_withdrawals: int
|
||||
pending_withdrawals: int
|
||||
total_cc_charged: float
|
||||
total_withdrawal_amount: float
|
||||
|
||||
|
||||
class RevenueStats(BaseModel):
|
||||
total_revenue: float
|
||||
revenue_this_month: float
|
||||
revenue_last_month: float
|
||||
platform_commission: float
|
||||
dealer_commission: float
|
||||
|
||||
|
||||
class ChartData(BaseModel):
|
||||
labels: List[str]
|
||||
values: List[int]
|
||||
|
||||
|
||||
class DailyStats(BaseModel):
|
||||
date: str
|
||||
users: int
|
||||
requests: int
|
||||
purchases: int
|
||||
revenue: float
|
||||
|
||||
|
||||
class RecentActivity(BaseModel):
|
||||
type: str
|
||||
title: str
|
||||
description: str
|
||||
time: str
|
||||
icon: str
|
||||
|
||||
|
||||
class TopDealer(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
dealer_code: str
|
||||
total_sales: int
|
||||
total_commission: float
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
def get_dashboard_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get comprehensive dashboard statistics"""
|
||||
today = datetime.utcnow().date()
|
||||
week_ago = today - timedelta(days=7)
|
||||
|
||||
# User stats
|
||||
total_users = db.query(func.count(User.id)).filter(User.is_admin == False).scalar() or 0
|
||||
new_users_today = db.query(func.count(User.id)).filter(
|
||||
and_(
|
||||
User.is_admin == False,
|
||||
func.date(User.created_at) == today
|
||||
)
|
||||
).scalar() or 0
|
||||
new_users_this_week = db.query(func.count(User.id)).filter(
|
||||
and_(
|
||||
User.is_admin == False,
|
||||
func.date(User.created_at) >= week_ago
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Dealer stats
|
||||
total_dealers = db.query(func.count(DealerInfo.id)).filter(DealerInfo.is_active == True).scalar() or 0
|
||||
pending_dealer_applications = db.query(func.count(DealerApplication.id)).filter(
|
||||
DealerApplication.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
# Car stats
|
||||
total_cars = db.query(func.count(Car.id)).scalar() or 0
|
||||
|
||||
# Vehicle request stats
|
||||
total_vehicle_requests = db.query(func.count(VehicleRequest.id)).scalar() or 0
|
||||
pending_requests = db.query(func.count(VehicleRequest.id)).filter(
|
||||
VehicleRequest.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
# Purchased vehicles
|
||||
total_purchased_vehicles = db.query(func.count(PurchasedVehicle.id)).scalar() or 0
|
||||
|
||||
# Inquiry stats
|
||||
total_inquiries = db.query(func.count(Inquiry.id)).scalar() or 0
|
||||
pending_inquiries = db.query(func.count(Inquiry.id)).filter(
|
||||
Inquiry.status == InquiryStatus.PENDING
|
||||
).scalar() or 0
|
||||
|
||||
# Share stats
|
||||
total_shares = db.query(func.count(VehicleShare.id)).scalar() or 0
|
||||
purchased_shares = db.query(func.count(VehicleShare.id)).filter(
|
||||
VehicleShare.is_purchased == True
|
||||
).scalar() or 0
|
||||
|
||||
# Withdrawal stats
|
||||
total_withdrawals = db.query(func.count(WithdrawalRequest.id)).scalar() or 0
|
||||
pending_withdrawals = db.query(func.count(WithdrawalRequest.id)).filter(
|
||||
WithdrawalRequest.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
# CC stats
|
||||
total_cc_charged = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
ChargeHistory.status == "completed"
|
||||
).scalar() or 0
|
||||
|
||||
total_withdrawal_amount = db.query(func.coalesce(func.sum(WithdrawalRequest.amount), 0)).filter(
|
||||
WithdrawalRequest.status == "completed"
|
||||
).scalar() or 0
|
||||
|
||||
return DashboardStats(
|
||||
total_users=total_users,
|
||||
new_users_today=new_users_today,
|
||||
new_users_this_week=new_users_this_week,
|
||||
total_dealers=total_dealers,
|
||||
pending_dealer_applications=pending_dealer_applications,
|
||||
total_cars=total_cars,
|
||||
total_vehicle_requests=total_vehicle_requests,
|
||||
pending_requests=pending_requests,
|
||||
total_purchased_vehicles=total_purchased_vehicles,
|
||||
total_inquiries=total_inquiries,
|
||||
pending_inquiries=pending_inquiries,
|
||||
total_shares=total_shares,
|
||||
purchased_shares=purchased_shares,
|
||||
total_withdrawals=total_withdrawals,
|
||||
pending_withdrawals=pending_withdrawals,
|
||||
total_cc_charged=float(total_cc_charged),
|
||||
total_withdrawal_amount=float(total_withdrawal_amount),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/revenue", response_model=RevenueStats)
|
||||
def get_revenue_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get revenue statistics"""
|
||||
today = datetime.utcnow().date()
|
||||
this_month_start = today.replace(day=1)
|
||||
last_month_end = this_month_start - timedelta(days=1)
|
||||
last_month_start = last_month_end.replace(day=1)
|
||||
|
||||
# Total CC charged as revenue
|
||||
total_revenue = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
ChargeHistory.status == "completed"
|
||||
).scalar() or 0
|
||||
|
||||
revenue_this_month = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
and_(
|
||||
ChargeHistory.status == "completed",
|
||||
func.date(ChargeHistory.created_at) >= this_month_start
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
revenue_last_month = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
and_(
|
||||
ChargeHistory.status == "completed",
|
||||
func.date(ChargeHistory.created_at) >= last_month_start,
|
||||
func.date(ChargeHistory.created_at) <= last_month_end
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Commission stats from purchased vehicles
|
||||
platform_commission = db.query(func.coalesce(func.sum(PurchasedVehicle.platform_commission), 0)).scalar() or 0
|
||||
dealer_commission = db.query(func.coalesce(func.sum(PurchasedVehicle.dealer_commission), 0)).scalar() or 0
|
||||
|
||||
return RevenueStats(
|
||||
total_revenue=float(total_revenue),
|
||||
revenue_this_month=float(revenue_this_month),
|
||||
revenue_last_month=float(revenue_last_month),
|
||||
platform_commission=float(platform_commission),
|
||||
dealer_commission=float(dealer_commission),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chart/users", response_model=ChartData)
|
||||
def get_user_chart_data(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get user registration chart data for last N days"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
count = db.query(func.count(User.id)).filter(
|
||||
and_(
|
||||
User.is_admin == False,
|
||||
func.date(User.created_at) == date
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/chart/requests", response_model=ChartData)
|
||||
def get_request_chart_data(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get vehicle request chart data for last N days"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
count = db.query(func.count(VehicleRequest.id)).filter(
|
||||
func.date(VehicleRequest.created_at) == date
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/chart/revenue", response_model=ChartData)
|
||||
def get_revenue_chart_data(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get daily revenue chart data for last N days"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
amount = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
|
||||
and_(
|
||||
ChargeHistory.status == "completed",
|
||||
func.date(ChargeHistory.created_at) == date
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(int(amount))
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/recent-activities", response_model=List[RecentActivity])
|
||||
def get_recent_activities(
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get recent activities across the platform"""
|
||||
activities = []
|
||||
|
||||
# Recent user registrations
|
||||
recent_users = db.query(User).filter(User.is_admin == False).order_by(
|
||||
desc(User.created_at)
|
||||
).limit(3).all()
|
||||
|
||||
for user in recent_users:
|
||||
activities.append({
|
||||
"type": "user",
|
||||
"title": "New User Registration",
|
||||
"description": f"{user.name or user.email} joined the platform",
|
||||
"time": user.created_at.isoformat() if user.created_at else "",
|
||||
"icon": "user"
|
||||
})
|
||||
|
||||
# Recent vehicle requests
|
||||
recent_requests = db.query(VehicleRequest).order_by(
|
||||
desc(VehicleRequest.created_at)
|
||||
).limit(3).all()
|
||||
|
||||
for req in recent_requests:
|
||||
activities.append({
|
||||
"type": "request",
|
||||
"title": "Vehicle Request",
|
||||
"description": f"Request #{req.id} - {req.status}",
|
||||
"time": req.created_at.isoformat() if req.created_at else "",
|
||||
"icon": "car"
|
||||
})
|
||||
|
||||
# Recent inquiries
|
||||
recent_inquiries = db.query(Inquiry).order_by(
|
||||
desc(Inquiry.created_at)
|
||||
).limit(3).all()
|
||||
|
||||
for inq in recent_inquiries:
|
||||
activities.append({
|
||||
"type": "inquiry",
|
||||
"title": "New Inquiry",
|
||||
"description": f"{inq.subject or 'General inquiry'} - {inq.status}",
|
||||
"time": inq.created_at.isoformat() if inq.created_at else "",
|
||||
"icon": "message"
|
||||
})
|
||||
|
||||
# Recent dealer applications
|
||||
recent_applications = db.query(DealerApplication).filter(
|
||||
DealerApplication.status == "pending"
|
||||
).order_by(desc(DealerApplication.applied_at)).limit(2).all()
|
||||
|
||||
for app in recent_applications:
|
||||
activities.append({
|
||||
"type": "dealer",
|
||||
"title": "Dealer Application",
|
||||
"description": f"{app.real_name} ({app.business_name}) applied",
|
||||
"time": app.applied_at.isoformat() if app.applied_at else "",
|
||||
"icon": "badge"
|
||||
})
|
||||
|
||||
# Recent withdrawals
|
||||
recent_withdrawals = db.query(WithdrawalRequest).filter(
|
||||
WithdrawalRequest.status == "pending"
|
||||
).order_by(desc(WithdrawalRequest.requested_at)).limit(2).all()
|
||||
|
||||
for wd in recent_withdrawals:
|
||||
activities.append({
|
||||
"type": "withdrawal",
|
||||
"title": "Withdrawal Request",
|
||||
"description": f"₩{wd.amount:,.0f} withdrawal requested",
|
||||
"time": wd.requested_at.isoformat() if wd.requested_at else "",
|
||||
"icon": "wallet"
|
||||
})
|
||||
|
||||
# Sort by time
|
||||
activities.sort(key=lambda x: x["time"], reverse=True)
|
||||
|
||||
return [RecentActivity(**a) for a in activities[:limit]]
|
||||
|
||||
|
||||
@router.get("/top-dealers", response_model=List[TopDealer])
|
||||
def get_top_dealers(
|
||||
limit: int = 5,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get top performing dealers"""
|
||||
# Get dealers with their stats
|
||||
dealers = db.query(
|
||||
DealerInfo,
|
||||
User.name,
|
||||
func.count(PurchasedVehicle.id).label("sales_count"),
|
||||
func.coalesce(func.sum(PurchasedVehicle.dealer_commission), 0).label("total_commission")
|
||||
).join(
|
||||
User, DealerInfo.user_id == User.id
|
||||
).outerjoin(
|
||||
PurchasedVehicle, DealerInfo.user_id == PurchasedVehicle.selected_dealer_id
|
||||
).filter(
|
||||
DealerInfo.is_active == True
|
||||
).group_by(
|
||||
DealerInfo.id, User.name
|
||||
).order_by(
|
||||
desc("sales_count")
|
||||
).limit(limit).all()
|
||||
|
||||
return [
|
||||
TopDealer(
|
||||
id=dealer.DealerInfo.id,
|
||||
name=dealer.name or "Unknown",
|
||||
dealer_code=dealer.DealerInfo.dealer_code,
|
||||
total_sales=dealer.sales_count,
|
||||
total_commission=float(dealer.total_commission)
|
||||
)
|
||||
for dealer in dealers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/pending-actions")
|
||||
def get_pending_actions(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get counts of pending items requiring admin action"""
|
||||
pending_requests = db.query(func.count(VehicleRequest.id)).filter(
|
||||
VehicleRequest.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
pending_inquiries = db.query(func.count(Inquiry.id)).filter(
|
||||
Inquiry.status == InquiryStatus.PENDING
|
||||
).scalar() or 0
|
||||
|
||||
pending_dealer_apps = db.query(func.count(DealerApplication.id)).filter(
|
||||
DealerApplication.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
pending_withdrawals = db.query(func.count(WithdrawalRequest.id)).filter(
|
||||
WithdrawalRequest.status == "pending"
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"pending_requests": pending_requests,
|
||||
"pending_inquiries": pending_inquiries,
|
||||
"pending_dealer_applications": pending_dealer_apps,
|
||||
"pending_withdrawals": pending_withdrawals,
|
||||
"total_pending": pending_requests + pending_inquiries + pending_dealer_apps + pending_withdrawals
|
||||
}
|
||||
254
backend/app/api/dealer.py
Normal file
254
backend/app/api/dealer.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import User, DealerApplication, DealerInfo
|
||||
from ..models.dealer import generate_dealer_code
|
||||
from ..schemas import (
|
||||
DealerApplicationCreate, DealerApplicationResponse,
|
||||
DealerApplicationReject, DealerInfoResponse, DealerPublicInfo,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import notify_dealer_approved, notify_dealer_rejected
|
||||
|
||||
router = APIRouter(prefix="/dealer", tags=["dealer"])
|
||||
|
||||
|
||||
@router.post("/apply", response_model=DealerApplicationResponse)
|
||||
def apply_dealer(
|
||||
application: DealerApplicationCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Submit a dealer application"""
|
||||
# Check if user already has a pending or approved application
|
||||
existing = db.query(DealerApplication).filter(
|
||||
DealerApplication.user_id == current_user.id,
|
||||
DealerApplication.status.in_(["pending", "approved"])
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if existing.status == "approved":
|
||||
raise HTTPException(status_code=400, detail="You are already a dealer")
|
||||
raise HTTPException(status_code=400, detail="You already have a pending application")
|
||||
|
||||
# Check if user is already a dealer
|
||||
if current_user.is_dealer:
|
||||
raise HTTPException(status_code=400, detail="You are already a dealer")
|
||||
|
||||
# Create new application
|
||||
new_application = DealerApplication(
|
||||
user_id=current_user.id,
|
||||
business_name=application.business_name,
|
||||
business_number=application.business_number,
|
||||
real_name=application.real_name,
|
||||
id_number_encrypted=application.id_number, # TODO: Encrypt this properly
|
||||
phone=application.phone,
|
||||
bank_name=application.bank_name,
|
||||
bank_account=application.bank_account,
|
||||
account_holder=application.account_holder,
|
||||
photo_url=application.photo_url,
|
||||
status="pending"
|
||||
)
|
||||
|
||||
db.add(new_application)
|
||||
db.commit()
|
||||
db.refresh(new_application)
|
||||
return new_application
|
||||
|
||||
|
||||
@router.get("/my-application", response_model=DealerApplicationResponse)
|
||||
def get_my_application(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's dealer application"""
|
||||
application = db.query(DealerApplication).filter(
|
||||
DealerApplication.user_id == current_user.id
|
||||
).order_by(DealerApplication.applied_at.desc()).first()
|
||||
|
||||
if not application:
|
||||
raise HTTPException(status_code=404, detail="No application found")
|
||||
|
||||
return application
|
||||
|
||||
|
||||
@router.get("/my-info", response_model=DealerInfoResponse)
|
||||
def get_my_dealer_info(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's dealer info (if approved)"""
|
||||
if not current_user.is_dealer:
|
||||
raise HTTPException(status_code=403, detail="You are not a dealer")
|
||||
|
||||
dealer_info = db.query(DealerInfo).filter(
|
||||
DealerInfo.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not dealer_info:
|
||||
raise HTTPException(status_code=404, detail="Dealer info not found")
|
||||
|
||||
return dealer_info
|
||||
|
||||
|
||||
@router.get("/list", response_model=List[DealerPublicInfo])
|
||||
def list_dealers(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get list of active dealers (public info only)"""
|
||||
dealers = db.query(DealerInfo).filter(
|
||||
DealerInfo.is_active == True
|
||||
).all()
|
||||
return dealers
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/applications", response_model=List[DealerApplicationResponse])
|
||||
def get_applications(
|
||||
status_filter: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all dealer applications"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(DealerApplication)
|
||||
if status_filter:
|
||||
query = query.filter(DealerApplication.status == status_filter)
|
||||
|
||||
applications = query.order_by(DealerApplication.applied_at.desc()).all()
|
||||
return applications
|
||||
|
||||
|
||||
@router.put("/admin/applications/{application_id}/approve", response_model=DealerInfoResponse)
|
||||
def approve_application(
|
||||
application_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Approve a dealer application"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
application = db.query(DealerApplication).filter(
|
||||
DealerApplication.id == application_id
|
||||
).first()
|
||||
|
||||
if not application:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
if application.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Application is not pending")
|
||||
|
||||
# Generate unique dealer code
|
||||
dealer_code = generate_dealer_code()
|
||||
while db.query(DealerInfo).filter(DealerInfo.dealer_code == dealer_code).first():
|
||||
dealer_code = generate_dealer_code()
|
||||
|
||||
# Create dealer info
|
||||
dealer_info = DealerInfo(
|
||||
user_id=application.user_id,
|
||||
dealer_code=dealer_code,
|
||||
business_name=application.business_name,
|
||||
real_name=application.real_name,
|
||||
phone=application.phone,
|
||||
photo_url=application.photo_url,
|
||||
bank_name=application.bank_name,
|
||||
bank_account=application.bank_account,
|
||||
account_holder=application.account_holder,
|
||||
)
|
||||
|
||||
# Update application status
|
||||
application.status = "approved"
|
||||
application.approved_at = datetime.utcnow()
|
||||
|
||||
# Update user is_dealer flag
|
||||
user = db.query(User).filter(User.id == application.user_id).first()
|
||||
user.is_dealer = True
|
||||
|
||||
db.add(dealer_info)
|
||||
db.commit()
|
||||
db.refresh(dealer_info)
|
||||
|
||||
# TODO: Generate dealer card image here
|
||||
# dealer_info.dealer_card_url = generate_dealer_card(dealer_info)
|
||||
# db.commit()
|
||||
|
||||
# Send notification to user about dealer approval
|
||||
notify_dealer_approved(db, application.user_id, dealer_code)
|
||||
|
||||
return dealer_info
|
||||
|
||||
|
||||
@router.put("/admin/applications/{application_id}/reject")
|
||||
def reject_application(
|
||||
application_id: int,
|
||||
reject_data: DealerApplicationReject,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Reject a dealer application"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
application = db.query(DealerApplication).filter(
|
||||
DealerApplication.id == application_id
|
||||
).first()
|
||||
|
||||
if not application:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
if application.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Application is not pending")
|
||||
|
||||
application.status = "rejected"
|
||||
application.rejected_reason = reject_data.reason
|
||||
|
||||
db.commit()
|
||||
|
||||
# Send notification to user about dealer rejection
|
||||
notify_dealer_rejected(db, application.user_id, reject_data.reason)
|
||||
|
||||
return {"message": "Application rejected", "reason": reject_data.reason}
|
||||
|
||||
|
||||
@router.get("/admin/dealers", response_model=List[DealerInfoResponse])
|
||||
def get_all_dealers(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all dealers with full info"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
dealers = db.query(DealerInfo).all()
|
||||
return dealers
|
||||
|
||||
|
||||
@router.put("/admin/dealers/{dealer_id}/toggle-active")
|
||||
def toggle_dealer_active(
|
||||
dealer_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Toggle dealer active status"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
dealer = db.query(DealerInfo).filter(DealerInfo.id == dealer_id).first()
|
||||
if not dealer:
|
||||
raise HTTPException(status_code=404, detail="Dealer not found")
|
||||
|
||||
dealer.is_active = not dealer.is_active
|
||||
|
||||
# Also update user's is_dealer status
|
||||
user = db.query(User).filter(User.id == dealer.user_id).first()
|
||||
if user:
|
||||
user.is_dealer = dealer.is_active
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Dealer {'activated' if dealer.is_active else 'deactivated'}", "is_active": dealer.is_active}
|
||||
247
backend/app/api/exchange_rate.py
Normal file
247
backend/app/api/exchange_rate.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Exchange Rate API - 환율 정보 조회 (한국수출입은행 API 연동)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.exchange_rate import ExchangeRate, ExchangeRateHistory
|
||||
from ..models.user import User
|
||||
from .auth import get_current_admin_user
|
||||
from ..services.exchange_rate_service import (
|
||||
update_exchange_rates,
|
||||
get_all_exchange_rates,
|
||||
convert_krw_to_currency,
|
||||
SUPPORTED_CURRENCIES
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/exchange-rate", tags=["Exchange Rate"])
|
||||
|
||||
|
||||
class ExchangeRateData(BaseModel):
|
||||
currency_code: str
|
||||
currency_name: str
|
||||
symbol: str
|
||||
deal_base_rate: float # 매매기준율 (1 USD = X KRW)
|
||||
ttb_rate: float # 전신환 받을때
|
||||
tts_rate: float # 전신환 보낼때
|
||||
weight_percent: float # 가중치 (%)
|
||||
adjusted_rate: float # 가중치 적용 환율
|
||||
source_date: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ExchangeRatesResponse(BaseModel):
|
||||
base_currency: str
|
||||
rates: List[ExchangeRateData]
|
||||
source: str
|
||||
last_updated: str
|
||||
|
||||
|
||||
class ExchangeRateWeightUpdate(BaseModel):
|
||||
currency_code: str
|
||||
weight_percent: float
|
||||
|
||||
|
||||
class ConvertRequest(BaseModel):
|
||||
amount: float
|
||||
from_currency: str = "KRW"
|
||||
to_currency: str
|
||||
|
||||
|
||||
class ConvertResponse(BaseModel):
|
||||
original_amount: float
|
||||
from_currency: str
|
||||
converted_amount: float
|
||||
to_currency: str
|
||||
rate_used: float
|
||||
|
||||
|
||||
@router.get("", response_model=ExchangeRatesResponse)
|
||||
async def get_exchange_rates(db: Session = Depends(get_db)):
|
||||
"""환율 정보 조회"""
|
||||
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
# DB에 데이터가 없으면 업데이트 시도
|
||||
if not rates:
|
||||
await update_exchange_rates(db)
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
rate_list = []
|
||||
for rate in rates:
|
||||
symbol = SUPPORTED_CURRENCIES.get(rate.currency_code, {}).get("symbol", "")
|
||||
rate_list.append(ExchangeRateData(
|
||||
currency_code=rate.currency_code,
|
||||
currency_name=rate.currency_name,
|
||||
symbol=symbol,
|
||||
deal_base_rate=rate.deal_base_rate,
|
||||
ttb_rate=rate.ttb_rate or rate.deal_base_rate,
|
||||
tts_rate=rate.tts_rate or rate.deal_base_rate,
|
||||
weight_percent=rate.weight_percent or 0.0,
|
||||
adjusted_rate=rate.adjusted_rate or rate.deal_base_rate,
|
||||
source_date=rate.source_date or "",
|
||||
updated_at=rate.updated_at.isoformat() if rate.updated_at else ""
|
||||
))
|
||||
|
||||
last_updated = ""
|
||||
if rates:
|
||||
latest = max(rates, key=lambda r: r.updated_at if r.updated_at else datetime.min)
|
||||
last_updated = latest.updated_at.isoformat() if latest.updated_at else ""
|
||||
|
||||
return ExchangeRatesResponse(
|
||||
base_currency="KRW",
|
||||
rates=rate_list,
|
||||
source="koreaexim",
|
||||
last_updated=last_updated
|
||||
)
|
||||
|
||||
|
||||
@router.get("/currency/{currency_code}")
|
||||
async def get_single_rate(currency_code: str, db: Session = Depends(get_db)):
|
||||
"""특정 통화 환율 조회"""
|
||||
rate = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == currency_code.upper(),
|
||||
ExchangeRate.is_active == True
|
||||
).first()
|
||||
|
||||
if not rate:
|
||||
raise HTTPException(status_code=404, detail=f"Currency {currency_code} not found")
|
||||
|
||||
symbol = SUPPORTED_CURRENCIES.get(rate.currency_code, {}).get("symbol", "")
|
||||
|
||||
return {
|
||||
"currency_code": rate.currency_code,
|
||||
"currency_name": rate.currency_name,
|
||||
"symbol": symbol,
|
||||
"deal_base_rate": rate.deal_base_rate,
|
||||
"adjusted_rate": rate.adjusted_rate,
|
||||
"weight_percent": rate.weight_percent,
|
||||
"source_date": rate.source_date,
|
||||
"updated_at": rate.updated_at.isoformat() if rate.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
@router.post("/convert", response_model=ConvertResponse)
|
||||
async def convert_currency(
|
||||
request: ConvertRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""통화 변환"""
|
||||
if request.from_currency.upper() != "KRW":
|
||||
raise HTTPException(status_code=400, detail="Currently only KRW conversion is supported")
|
||||
|
||||
converted = convert_krw_to_currency(db, request.amount, request.to_currency.upper())
|
||||
|
||||
if converted is None:
|
||||
raise HTTPException(status_code=404, detail=f"Currency {request.to_currency} not found")
|
||||
|
||||
rate = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == request.to_currency.upper()
|
||||
).first()
|
||||
|
||||
return ConvertResponse(
|
||||
original_amount=request.amount,
|
||||
from_currency=request.from_currency.upper(),
|
||||
converted_amount=round(converted, 2),
|
||||
to_currency=request.to_currency.upper(),
|
||||
rate_used=rate.adjusted_rate if rate else 0
|
||||
)
|
||||
|
||||
|
||||
@router.get("/weights")
|
||||
async def get_exchange_rate_weights(db: Session = Depends(get_db)):
|
||||
"""환율 가중치 설정 조회"""
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
return {
|
||||
rate.currency_code.lower(): rate.weight_percent or 0.0
|
||||
for rate in rates
|
||||
}
|
||||
|
||||
|
||||
@router.put("/weights/{currency_code}")
|
||||
async def update_exchange_rate_weight(
|
||||
currency_code: str,
|
||||
weight_percent: float,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""환율 가중치 수정 (관리자 전용)"""
|
||||
rate = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == currency_code.upper()
|
||||
).first()
|
||||
|
||||
if not rate:
|
||||
raise HTTPException(status_code=404, detail=f"Currency {currency_code} not found")
|
||||
|
||||
rate.weight_percent = weight_percent
|
||||
rate.adjusted_rate = rate.deal_base_rate * (1 + weight_percent / 100)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Weight updated successfully",
|
||||
"currency_code": rate.currency_code,
|
||||
"weight_percent": rate.weight_percent,
|
||||
"adjusted_rate": rate.adjusted_rate
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_exchange_rates(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""환율 강제 갱신 (관리자 전용)"""
|
||||
result = await update_exchange_rates(db, force=True)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/history/{currency_code}")
|
||||
async def get_exchange_rate_history(
|
||||
currency_code: str,
|
||||
limit: int = 30,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""환율 변동 이력 조회"""
|
||||
history = db.query(ExchangeRateHistory).filter(
|
||||
ExchangeRateHistory.currency_code == currency_code.upper()
|
||||
).order_by(ExchangeRateHistory.created_at.desc()).limit(limit).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"currency_code": h.currency_code,
|
||||
"deal_base_rate": h.deal_base_rate,
|
||||
"source_date": h.source_date,
|
||||
"created_at": h.created_at.isoformat() if h.created_at else None
|
||||
}
|
||||
for h in history
|
||||
]
|
||||
|
||||
|
||||
# 프론트엔드용 간단 API
|
||||
@router.get("/simple")
|
||||
async def get_simple_rates(db: Session = Depends(get_db)):
|
||||
"""프론트엔드용 간단 환율 정보"""
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
# DB에 데이터가 없으면 업데이트 시도
|
||||
if not rates:
|
||||
await update_exchange_rates(db)
|
||||
rates = get_all_exchange_rates(db)
|
||||
|
||||
result = {}
|
||||
for rate in rates:
|
||||
result[rate.currency_code] = {
|
||||
"rate": rate.adjusted_rate, # KRW per 1 unit (e.g., 1 USD = 1450 KRW)
|
||||
"symbol": SUPPORTED_CURRENCIES.get(rate.currency_code, {}).get("symbol", ""),
|
||||
"name": rate.currency_name
|
||||
}
|
||||
|
||||
return result
|
||||
265
backend/app/api/hero_banners.py
Normal file
265
backend/app/api/hero_banners.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import uuid
|
||||
import aiofiles
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.hero_banner import HeroBanner, HeroBannerSettings
|
||||
from ..schemas.hero_banner import (
|
||||
HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse,
|
||||
HeroBannerListResponse, HeroBannerLocalizedResponse,
|
||||
HeroBannerSettingsUpdate, HeroBannerSettingsResponse,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from ..models import User
|
||||
from ..config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/hero-banners", tags=["hero-banners"])
|
||||
|
||||
settings = get_settings()
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
|
||||
|
||||
def get_localized_field(obj, field: str, lang: str) -> Optional[str]:
|
||||
"""Get localized field value with fallback to Korean then English"""
|
||||
localized = getattr(obj, f"{field}_{lang}", None)
|
||||
if localized:
|
||||
return localized
|
||||
# Fallback to Korean
|
||||
ko_value = getattr(obj, f"{field}_ko", None)
|
||||
if ko_value:
|
||||
return ko_value
|
||||
# Fallback to English
|
||||
return getattr(obj, f"{field}_en", None)
|
||||
|
||||
|
||||
# ==================== Public Endpoints ====================
|
||||
|
||||
@router.get("/", response_model=List[HeroBannerLocalizedResponse])
|
||||
def get_hero_banners(
|
||||
lang: str = Query("ko", regex="^(ko|en|mn)$"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""활성 히어로 배너 목록 조회 (Public)"""
|
||||
banners = db.query(HeroBanner).filter(
|
||||
HeroBanner.is_active == True
|
||||
).order_by(HeroBanner.display_order.asc(), HeroBanner.id.desc()).all()
|
||||
|
||||
result = []
|
||||
for b in banners:
|
||||
result.append(HeroBannerLocalizedResponse(
|
||||
id=b.id,
|
||||
title=get_localized_field(b, "title", lang),
|
||||
subtitle=get_localized_field(b, "subtitle", lang),
|
||||
image_url=b.image_url,
|
||||
link_url=b.link_url,
|
||||
car_id=b.car_id,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/check-car/{car_id}")
|
||||
def check_banner_car(car_id: int, db: Session = Depends(get_db)):
|
||||
"""차량이 Hero Banner에 연결되어 있는지 확인 (Public)
|
||||
|
||||
Banner에 연결된 차량은 샘플로 모든 정보를 무료로 공개합니다.
|
||||
"""
|
||||
banner = db.query(HeroBanner).filter(
|
||||
HeroBanner.car_id == car_id,
|
||||
HeroBanner.is_active == True
|
||||
).first()
|
||||
|
||||
return {
|
||||
"car_id": car_id,
|
||||
"is_banner_car": banner is not None,
|
||||
"banner_id": banner.id if banner else None
|
||||
}
|
||||
|
||||
|
||||
@router.get("/settings", response_model=HeroBannerSettingsResponse)
|
||||
def get_banner_settings(db: Session = Depends(get_db)):
|
||||
"""배너 슬라이더 설정 조회 (Public)"""
|
||||
settings_obj = db.query(HeroBannerSettings).first()
|
||||
if not settings_obj:
|
||||
# 기본 설정 생성
|
||||
settings_obj = HeroBannerSettings(
|
||||
slide_interval=3000,
|
||||
animation_type="film-strip",
|
||||
image_width=500,
|
||||
image_height=300,
|
||||
auto_play=True,
|
||||
)
|
||||
db.add(settings_obj)
|
||||
db.commit()
|
||||
db.refresh(settings_obj)
|
||||
return settings_obj
|
||||
|
||||
|
||||
# ==================== Admin Endpoints ====================
|
||||
|
||||
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""관리자 권한 확인 (임시: 모든 로그인 사용자 허용)"""
|
||||
# TODO: 실제 관리자 역할 체크 추가
|
||||
# if current_user.role != "admin":
|
||||
# raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/admin/list", response_model=List[HeroBannerListResponse])
|
||||
def admin_get_banners(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""모든 히어로 배너 조회 (Admin)"""
|
||||
banners = db.query(HeroBanner).order_by(
|
||||
HeroBanner.display_order.asc(),
|
||||
HeroBanner.id.desc()
|
||||
).all()
|
||||
return banners
|
||||
|
||||
|
||||
@router.get("/admin/{banner_id}", response_model=HeroBannerResponse)
|
||||
def admin_get_banner(
|
||||
banner_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""히어로 배너 상세 조회 (Admin)"""
|
||||
banner = db.query(HeroBanner).filter(HeroBanner.id == banner_id).first()
|
||||
if not banner:
|
||||
raise HTTPException(status_code=404, detail="Banner not found")
|
||||
return banner
|
||||
|
||||
|
||||
@router.post("/admin", response_model=HeroBannerResponse)
|
||||
def create_banner(
|
||||
banner_data: HeroBannerCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""히어로 배너 생성 (Admin)"""
|
||||
banner = HeroBanner(**banner_data.model_dump())
|
||||
db.add(banner)
|
||||
db.commit()
|
||||
db.refresh(banner)
|
||||
return banner
|
||||
|
||||
|
||||
@router.put("/admin/{banner_id}", response_model=HeroBannerResponse)
|
||||
def update_banner(
|
||||
banner_id: int,
|
||||
banner_data: HeroBannerUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""히어로 배너 수정 (Admin)"""
|
||||
banner = db.query(HeroBanner).filter(HeroBanner.id == banner_id).first()
|
||||
if not banner:
|
||||
raise HTTPException(status_code=404, detail="Banner not found")
|
||||
|
||||
update_data = banner_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(banner, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(banner)
|
||||
return banner
|
||||
|
||||
|
||||
@router.delete("/admin/{banner_id}")
|
||||
def delete_banner(
|
||||
banner_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""히어로 배너 삭제 (Admin)"""
|
||||
banner = db.query(HeroBanner).filter(HeroBanner.id == banner_id).first()
|
||||
if not banner:
|
||||
raise HTTPException(status_code=404, detail="Banner not found")
|
||||
|
||||
# 로컬 이미지 파일 삭제
|
||||
if banner.image_url and banner.image_url.startswith("/uploads/"):
|
||||
try:
|
||||
filepath = os.path.join(settings.UPLOAD_DIR if hasattr(settings, 'UPLOAD_DIR') else "./uploads",
|
||||
os.path.basename(banner.image_url))
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db.delete(banner)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Banner deleted successfully"}
|
||||
|
||||
|
||||
@router.put("/admin/settings", response_model=HeroBannerSettingsResponse)
|
||||
def update_banner_settings(
|
||||
settings_data: HeroBannerSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""배너 슬라이더 설정 수정 (Admin)"""
|
||||
settings_obj = db.query(HeroBannerSettings).first()
|
||||
if not settings_obj:
|
||||
settings_obj = HeroBannerSettings()
|
||||
db.add(settings_obj)
|
||||
|
||||
update_data = settings_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(settings_obj, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings_obj)
|
||||
return settings_obj
|
||||
|
||||
|
||||
# ==================== Image Upload ====================
|
||||
|
||||
@router.post("/admin/upload-image")
|
||||
async def upload_banner_image(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""배너 이미지 업로드 (Admin)"""
|
||||
# 파일 확장자 검증
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File type not allowed. Allowed: {ALLOWED_EXTENSIONS}"
|
||||
)
|
||||
|
||||
# 파일 읽기 및 크기 검증
|
||||
contents = await file.read()
|
||||
max_size = 10 * 1024 * 1024 # 10MB
|
||||
if len(contents) > max_size:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File too large. Max size: {max_size / 1024 / 1024}MB"
|
||||
)
|
||||
|
||||
# 업로드 디렉토리 생성
|
||||
upload_dir = "./uploads/hero-banners"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# 고유 파일명 생성
|
||||
filename = f"hero_{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(upload_dir, filename)
|
||||
|
||||
# 파일 저장
|
||||
async with aiofiles.open(filepath, 'wb') as f:
|
||||
await f.write(contents)
|
||||
|
||||
# 상대 URL 반환
|
||||
image_url = f"/uploads/hero-banners/{filename}"
|
||||
|
||||
return {
|
||||
"message": "Image uploaded successfully",
|
||||
"image_url": image_url,
|
||||
"filename": filename,
|
||||
}
|
||||
326
backend/app/api/inquiries.py
Normal file
326
backend/app/api/inquiries.py
Normal file
@@ -0,0 +1,326 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..models.inquiry import Inquiry, InquiryMessage, InquiryStatus
|
||||
from ..schemas.inquiry import (
|
||||
InquiryCreate, InquiryResponse, InquiryListResponse,
|
||||
InquiryMessageCreate, InquiryMessageResponse, InquiryWithMessages,
|
||||
AdminInquiryRespond, AdminInquiryUpdateStatus
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import create_notification
|
||||
|
||||
router = APIRouter(prefix="/inquiries", tags=["inquiries"])
|
||||
|
||||
|
||||
# =====================
|
||||
# User Endpoints
|
||||
# =====================
|
||||
|
||||
@router.get("", response_model=List[InquiryResponse])
|
||||
def get_inquiries(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's inquiries (legacy endpoint)"""
|
||||
return db.query(Inquiry).filter(Inquiry.user_id == current_user.id).order_by(desc(Inquiry.created_at)).all()
|
||||
|
||||
|
||||
@router.post("", response_model=InquiryResponse)
|
||||
def create_inquiry(
|
||||
inquiry_data: InquiryCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new inquiry"""
|
||||
inquiry = Inquiry(
|
||||
user_id=current_user.id,
|
||||
car_id=inquiry_data.car_id,
|
||||
category=inquiry_data.category,
|
||||
subject=inquiry_data.subject or f"{inquiry_data.category} 문의",
|
||||
message=inquiry_data.message,
|
||||
contact_email=inquiry_data.contact_email or current_user.email,
|
||||
contact_phone=inquiry_data.contact_phone or current_user.phone,
|
||||
status=InquiryStatus.PENDING
|
||||
)
|
||||
|
||||
db.add(inquiry)
|
||||
db.commit()
|
||||
db.refresh(inquiry)
|
||||
|
||||
return inquiry
|
||||
|
||||
|
||||
@router.get("/my-inquiries", response_model=InquiryListResponse)
|
||||
def get_my_inquiries(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
status: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's inquiries with pagination"""
|
||||
query = db.query(Inquiry).filter(Inquiry.user_id == current_user.id)
|
||||
|
||||
if status:
|
||||
query = query.filter(Inquiry.status == status)
|
||||
|
||||
total = query.count()
|
||||
inquiries = query.order_by(desc(Inquiry.created_at)) \
|
||||
.offset((page - 1) * page_size) \
|
||||
.limit(page_size) \
|
||||
.all()
|
||||
|
||||
return InquiryListResponse(
|
||||
inquiries=[InquiryResponse.model_validate(i) for i in inquiries],
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my-inquiries/{inquiry_id}", response_model=InquiryWithMessages)
|
||||
def get_my_inquiry_detail(
|
||||
inquiry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get details of a specific inquiry with messages"""
|
||||
inquiry = db.query(Inquiry).filter(
|
||||
Inquiry.id == inquiry_id,
|
||||
Inquiry.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
messages = db.query(InquiryMessage).filter(
|
||||
InquiryMessage.inquiry_id == inquiry_id
|
||||
).order_by(InquiryMessage.created_at).all()
|
||||
|
||||
return InquiryWithMessages(
|
||||
inquiry=InquiryResponse.model_validate(inquiry),
|
||||
messages=[InquiryMessageResponse.model_validate(m) for m in messages]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/my-inquiries/{inquiry_id}/message", response_model=InquiryMessageResponse)
|
||||
def add_message_to_inquiry(
|
||||
inquiry_id: int,
|
||||
message_data: InquiryMessageCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a message to an existing inquiry"""
|
||||
inquiry = db.query(Inquiry).filter(
|
||||
Inquiry.id == inquiry_id,
|
||||
Inquiry.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
if inquiry.status == InquiryStatus.CLOSED:
|
||||
raise HTTPException(status_code=400, detail="Cannot add message to closed inquiry")
|
||||
|
||||
message = InquiryMessage(
|
||||
inquiry_id=inquiry_id,
|
||||
user_id=current_user.id,
|
||||
message=message_data.message,
|
||||
is_admin=False
|
||||
)
|
||||
|
||||
# Update inquiry status if it was resolved
|
||||
if inquiry.status == InquiryStatus.RESOLVED:
|
||||
inquiry.status = InquiryStatus.IN_PROGRESS
|
||||
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@router.get("/{inquiry_id}", response_model=InquiryResponse)
|
||||
def get_inquiry(
|
||||
inquiry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get inquiry detail (legacy endpoint)"""
|
||||
inquiry = db.query(Inquiry).filter(
|
||||
Inquiry.id == inquiry_id,
|
||||
Inquiry.user_id == current_user.id
|
||||
).first()
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
return inquiry
|
||||
|
||||
|
||||
# =====================
|
||||
# Admin Endpoints
|
||||
# =====================
|
||||
|
||||
@router.get("/admin/list", response_model=InquiryListResponse)
|
||||
def admin_get_all_inquiries(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all inquiries"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(Inquiry)
|
||||
|
||||
if status:
|
||||
query = query.filter(Inquiry.status == status)
|
||||
if category:
|
||||
query = query.filter(Inquiry.category == category)
|
||||
|
||||
total = query.count()
|
||||
inquiries = query.order_by(desc(Inquiry.created_at)) \
|
||||
.offset((page - 1) * page_size) \
|
||||
.limit(page_size) \
|
||||
.all()
|
||||
|
||||
return InquiryListResponse(
|
||||
inquiries=[InquiryResponse.model_validate(i) for i in inquiries],
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/{inquiry_id}", response_model=InquiryWithMessages)
|
||||
def admin_get_inquiry_detail(
|
||||
inquiry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get inquiry details with messages"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
inquiry = db.query(Inquiry).filter(Inquiry.id == inquiry_id).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
messages = db.query(InquiryMessage).filter(
|
||||
InquiryMessage.inquiry_id == inquiry_id
|
||||
).order_by(InquiryMessage.created_at).all()
|
||||
|
||||
return InquiryWithMessages(
|
||||
inquiry=InquiryResponse.model_validate(inquiry),
|
||||
messages=[InquiryMessageResponse.model_validate(m) for m in messages]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/{inquiry_id}/respond", response_model=InquiryMessageResponse)
|
||||
def admin_respond_to_inquiry(
|
||||
inquiry_id: int,
|
||||
response_data: AdminInquiryRespond,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Respond to an inquiry"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
inquiry = db.query(Inquiry).filter(Inquiry.id == inquiry_id).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
# Create message
|
||||
message = InquiryMessage(
|
||||
inquiry_id=inquiry_id,
|
||||
user_id=current_user.id,
|
||||
message=response_data.message,
|
||||
is_admin=True
|
||||
)
|
||||
|
||||
# Update inquiry
|
||||
inquiry.admin_response = response_data.message
|
||||
inquiry.responded_at = datetime.utcnow()
|
||||
inquiry.responded_by = current_user.id
|
||||
|
||||
if response_data.status:
|
||||
inquiry.status = response_data.status
|
||||
elif inquiry.status == InquiryStatus.PENDING:
|
||||
inquiry.status = InquiryStatus.IN_PROGRESS
|
||||
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
# Send notification to user
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=inquiry.user_id,
|
||||
notification_type="system",
|
||||
title="문의 답변 도착",
|
||||
message=f"'{inquiry.subject}' 문의에 답변이 등록되었습니다.",
|
||||
link="/contact"
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@router.put("/admin/{inquiry_id}/status", response_model=InquiryResponse)
|
||||
def admin_update_inquiry_status(
|
||||
inquiry_id: int,
|
||||
status_data: AdminInquiryUpdateStatus,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Update inquiry status"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
inquiry = db.query(Inquiry).filter(Inquiry.id == inquiry_id).first()
|
||||
|
||||
if not inquiry:
|
||||
raise HTTPException(status_code=404, detail="Inquiry not found")
|
||||
|
||||
valid_statuses = [InquiryStatus.PENDING, InquiryStatus.IN_PROGRESS, InquiryStatus.RESOLVED, InquiryStatus.CLOSED]
|
||||
if status_data.status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status. Must be one of: {valid_statuses}"
|
||||
)
|
||||
|
||||
inquiry.status = status_data.status
|
||||
db.commit()
|
||||
db.refresh(inquiry)
|
||||
|
||||
return inquiry
|
||||
|
||||
|
||||
@router.get("/admin/stats")
|
||||
def admin_get_inquiry_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get inquiry statistics"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
total = db.query(Inquiry).count()
|
||||
pending = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.PENDING).count()
|
||||
in_progress = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.IN_PROGRESS).count()
|
||||
resolved = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.RESOLVED).count()
|
||||
closed = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.CLOSED).count()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"pending": pending,
|
||||
"in_progress": in_progress,
|
||||
"resolved": resolved,
|
||||
"closed": closed
|
||||
}
|
||||
363
backend/app/api/notification.py
Normal file
363
backend/app/api/notification.py
Normal file
@@ -0,0 +1,363 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User, Notification
|
||||
from ..schemas.notification import (
|
||||
NotificationCreate, NotificationResponse,
|
||||
NotificationListResponse, NotificationMarkRead
|
||||
)
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
# =====================
|
||||
# Helper Functions
|
||||
# =====================
|
||||
|
||||
def create_notification(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
notification_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
link: Optional[str] = None,
|
||||
related_id: Optional[int] = None,
|
||||
related_type: Optional[str] = None
|
||||
) -> Notification:
|
||||
"""Create a new notification"""
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
notification_type=notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
link=link,
|
||||
related_id=related_id,
|
||||
related_type=related_type
|
||||
)
|
||||
db.add(notification)
|
||||
db.commit()
|
||||
db.refresh(notification)
|
||||
return notification
|
||||
|
||||
|
||||
def notify_vehicle_recommended(db: Session, user_id: int, request_id: int, vehicle_count: int):
|
||||
"""Notify user when vehicles are recommended for their request"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="vehicle_recommended",
|
||||
title="차량 추천 완료",
|
||||
message=f"{vehicle_count}대의 차량이 추천되었습니다. 지금 확인해보세요!",
|
||||
link=f"/my-request",
|
||||
related_id=request_id,
|
||||
related_type="vehicle_request"
|
||||
)
|
||||
|
||||
|
||||
def notify_shipping_update(db: Session, user_id: int, vehicle_id: int, status: int, car_name: str):
|
||||
"""Notify user when shipping status changes"""
|
||||
status_names = {
|
||||
1: "구매완료",
|
||||
2: "인천항 도착",
|
||||
3: "텐진항 도착",
|
||||
4: "자먼우드 도착",
|
||||
5: "울란바토르 도착",
|
||||
6: "통관 진행중",
|
||||
7: "배송완료"
|
||||
}
|
||||
status_name = status_names.get(status, f"상태 {status}")
|
||||
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="shipping_update",
|
||||
title="배송 상태 업데이트",
|
||||
message=f"{car_name}: {status_name}",
|
||||
link=f"/find-my-car",
|
||||
related_id=vehicle_id,
|
||||
related_type="purchased_vehicle"
|
||||
)
|
||||
|
||||
|
||||
def notify_withdrawal_processed(db: Session, user_id: int, withdrawal_id: int, status: str, amount: float):
|
||||
"""Notify user when withdrawal request is processed"""
|
||||
status_messages = {
|
||||
"approved": f"출금 신청이 승인되었습니다. {amount:,.0f}원이 곧 입금됩니다.",
|
||||
"completed": f"출금 완료! {amount:,.0f}원이 입금되었습니다.",
|
||||
"rejected": "출금 신청이 거부되었습니다. 관리자에게 문의해주세요."
|
||||
}
|
||||
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="withdrawal_processed",
|
||||
title="출금 처리 알림",
|
||||
message=status_messages.get(status, "출금 상태가 변경되었습니다."),
|
||||
link="/withdrawal",
|
||||
related_id=withdrawal_id,
|
||||
related_type="withdrawal"
|
||||
)
|
||||
|
||||
|
||||
def notify_referral_reward(db: Session, user_id: int, reward_amount: float, referred_name: str):
|
||||
"""Notify user when they receive referral reward"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="referral_reward",
|
||||
title="레퍼럴 보상 적립",
|
||||
message=f"{referred_name}님의 충전으로 {reward_amount:,.0f}원이 적립되었습니다!",
|
||||
link="/withdrawal",
|
||||
related_type="referral"
|
||||
)
|
||||
|
||||
|
||||
def notify_dealer_approved(db: Session, user_id: int, dealer_code: str):
|
||||
"""Notify user when dealer application is approved"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="dealer_approved",
|
||||
title="딜러 승인 완료",
|
||||
message=f"딜러 승인이 완료되었습니다! 딜러 코드: {dealer_code}",
|
||||
link="/dealer/my-card",
|
||||
related_type="dealer"
|
||||
)
|
||||
|
||||
|
||||
def notify_dealer_rejected(db: Session, user_id: int, reason: str):
|
||||
"""Notify user when dealer application is rejected"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="dealer_rejected",
|
||||
title="딜러 신청 거부",
|
||||
message=f"딜러 신청이 거부되었습니다. 사유: {reason}",
|
||||
link="/dealer/apply",
|
||||
related_type="dealer"
|
||||
)
|
||||
|
||||
|
||||
def notify_share_purchased(db: Session, user_id: int, share_id: int, reward_amount: float, car_name: str):
|
||||
"""Notify user when their shared vehicle is purchased"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="share_purchased",
|
||||
title="공유 차량 판매 완료",
|
||||
message=f"{car_name} 판매 완료! 리워드 {reward_amount:,.0f}원이 적립되었습니다.",
|
||||
link="/withdrawal",
|
||||
related_id=share_id,
|
||||
related_type="vehicle_share"
|
||||
)
|
||||
|
||||
|
||||
def notify_payment_confirmed(db: Session, user_id: int, charge_id: int, amount: float, cc_amount: int):
|
||||
"""Notify user when payment is confirmed"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="payment_confirmed",
|
||||
title="결제 확인 완료",
|
||||
message=f"결제가 확인되었습니다! ${amount:.2f} → {cc_amount} CC가 충전되었습니다.",
|
||||
link="/charge",
|
||||
related_id=charge_id,
|
||||
related_type="charge"
|
||||
)
|
||||
|
||||
|
||||
def notify_inquiry_reply(db: Session, user_id: int, inquiry_id: int, subject: str = None):
|
||||
"""Notify user when admin replies to their inquiry"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="inquiry_reply",
|
||||
title="문의 답변 등록",
|
||||
message=f"문의에 답변이 등록되었습니다." + (f" ({subject})" if subject else ""),
|
||||
link=f"/my-inquiries/{inquiry_id}",
|
||||
related_id=inquiry_id,
|
||||
related_type="inquiry"
|
||||
)
|
||||
|
||||
|
||||
def notify_system(db: Session, user_id: int, title: str, message: str, link: Optional[str] = None):
|
||||
"""Send a general system notification to a user"""
|
||||
return create_notification(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type="system",
|
||||
title=title,
|
||||
message=message,
|
||||
link=link
|
||||
)
|
||||
|
||||
|
||||
# =====================
|
||||
# User Endpoints
|
||||
# =====================
|
||||
|
||||
@router.get("/", response_model=NotificationListResponse)
|
||||
def get_notifications(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
unread_only: bool = False,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's notifications"""
|
||||
query = db.query(Notification).filter(Notification.user_id == current_user.id)
|
||||
|
||||
if unread_only:
|
||||
query = query.filter(Notification.is_read == False)
|
||||
|
||||
total = query.count()
|
||||
unread_count = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False
|
||||
).count()
|
||||
|
||||
notifications = query.order_by(desc(Notification.created_at)) \
|
||||
.offset((page - 1) * page_size) \
|
||||
.limit(page_size) \
|
||||
.all()
|
||||
|
||||
return NotificationListResponse(
|
||||
notifications=[NotificationResponse.model_validate(n) for n in notifications],
|
||||
unread_count=unread_count,
|
||||
total=total
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
def get_unread_count(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get count of unread notifications"""
|
||||
count = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False
|
||||
).count()
|
||||
|
||||
return {"unread_count": count}
|
||||
|
||||
|
||||
@router.post("/mark-read")
|
||||
def mark_as_read(
|
||||
data: NotificationMarkRead,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark notifications as read"""
|
||||
notifications = db.query(Notification).filter(
|
||||
Notification.id.in_(data.notification_ids),
|
||||
Notification.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
for notification in notifications:
|
||||
notification.is_read = True
|
||||
notification.read_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Marked {len(notifications)} notifications as read"}
|
||||
|
||||
|
||||
@router.post("/mark-all-read")
|
||||
def mark_all_as_read(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark all notifications as read"""
|
||||
count = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.is_read == False
|
||||
).update({
|
||||
"is_read": True,
|
||||
"read_at": datetime.utcnow()
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Marked {count} notifications as read"}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
def delete_notification(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a notification"""
|
||||
notification = db.query(Notification).filter(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not notification:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
|
||||
db.delete(notification)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Notification deleted"}
|
||||
|
||||
|
||||
# =====================
|
||||
# Admin Endpoints
|
||||
# =====================
|
||||
|
||||
@router.post("/admin/send")
|
||||
def admin_send_notification(
|
||||
notification_data: NotificationCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Send notification to a user"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
notification = create_notification(
|
||||
db=db,
|
||||
user_id=notification_data.user_id,
|
||||
notification_type=notification_data.notification_type,
|
||||
title=notification_data.title,
|
||||
message=notification_data.message,
|
||||
link=notification_data.link,
|
||||
related_id=notification_data.related_id,
|
||||
related_type=notification_data.related_type
|
||||
)
|
||||
|
||||
return NotificationResponse.model_validate(notification)
|
||||
|
||||
|
||||
@router.post("/admin/send-all")
|
||||
def admin_send_to_all(
|
||||
title: str,
|
||||
message: str,
|
||||
link: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Send notification to all users"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
users = db.query(User).filter(User.is_active == True).all()
|
||||
|
||||
for user in users:
|
||||
create_notification(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
notification_type="system",
|
||||
title=title,
|
||||
message=message,
|
||||
link=link
|
||||
)
|
||||
|
||||
return {"message": f"Sent notification to {len(users)} users"}
|
||||
276
backend/app/api/push.py
Normal file
276
backend/app/api/push.py
Normal file
@@ -0,0 +1,276 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from ..database import get_db
|
||||
from ..models import User, PushSubscription, UserNotificationPreference
|
||||
from .auth import get_current_user, get_current_admin_user
|
||||
|
||||
router = APIRouter(prefix="/push", tags=["Push Notifications"])
|
||||
|
||||
|
||||
# VAPID keys for web push (in production, store these securely)
|
||||
# Generate these using: npx web-push generate-vapid-keys
|
||||
VAPID_PUBLIC_KEY = "BMjR7pDj6PUjFo8VkA4f1BYhOAzGhJPcVnT7mJ6Bq8jG9yYKvN8dZ5jT3pQ2sL9wR0xF4bM1nK3vH5uC7yX2aE0"
|
||||
|
||||
|
||||
class PushSubscriptionCreate(BaseModel):
|
||||
endpoint: str
|
||||
p256dh_key: str
|
||||
auth_key: str
|
||||
device_info: Optional[str] = None
|
||||
|
||||
|
||||
class NotificationPreferenceUpdate(BaseModel):
|
||||
vehicle_recommended: Optional[bool] = None
|
||||
shipping_update: Optional[bool] = None
|
||||
payment_confirmed: Optional[bool] = None
|
||||
withdrawal_processed: Optional[bool] = None
|
||||
dealer_status: Optional[bool] = None
|
||||
share_purchased: Optional[bool] = None
|
||||
referral_reward: Optional[bool] = None
|
||||
inquiry_reply: Optional[bool] = None
|
||||
system_announcements: Optional[bool] = None
|
||||
push_enabled: Optional[bool] = None
|
||||
email_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/vapid-key")
|
||||
def get_vapid_public_key():
|
||||
"""Get VAPID public key for push subscription"""
|
||||
return {"public_key": VAPID_PUBLIC_KEY}
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
def subscribe_push(
|
||||
subscription: PushSubscriptionCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Subscribe to push notifications"""
|
||||
# Check if subscription already exists
|
||||
existing = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == current_user.id,
|
||||
PushSubscription.endpoint == subscription.endpoint
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing subscription
|
||||
existing.p256dh_key = subscription.p256dh_key
|
||||
existing.auth_key = subscription.auth_key
|
||||
existing.device_info = subscription.device_info
|
||||
existing.is_active = True
|
||||
existing.last_used_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new subscription
|
||||
new_sub = PushSubscription(
|
||||
user_id=current_user.id,
|
||||
endpoint=subscription.endpoint,
|
||||
p256dh_key=subscription.p256dh_key,
|
||||
auth_key=subscription.auth_key,
|
||||
device_info=subscription.device_info,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Push subscription saved successfully"}
|
||||
|
||||
|
||||
@router.delete("/unsubscribe")
|
||||
def unsubscribe_push(
|
||||
endpoint: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Unsubscribe from push notifications"""
|
||||
subscription = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == current_user.id,
|
||||
PushSubscription.endpoint == endpoint
|
||||
).first()
|
||||
|
||||
if subscription:
|
||||
subscription.is_active = False
|
||||
db.commit()
|
||||
|
||||
return {"message": "Push subscription removed"}
|
||||
|
||||
|
||||
@router.get("/subscriptions")
|
||||
def get_my_subscriptions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's active push subscriptions"""
|
||||
subscriptions = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == current_user.id,
|
||||
PushSubscription.is_active == True
|
||||
).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": sub.id,
|
||||
"endpoint": sub.endpoint[:50] + "..." if len(sub.endpoint) > 50 else sub.endpoint,
|
||||
"device_info": sub.device_info,
|
||||
"created_at": sub.created_at.isoformat() if sub.created_at else None,
|
||||
"last_used_at": sub.last_used_at.isoformat() if sub.last_used_at else None
|
||||
}
|
||||
for sub in subscriptions
|
||||
]
|
||||
|
||||
|
||||
@router.get("/preferences")
|
||||
def get_notification_preferences(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's notification preferences"""
|
||||
prefs = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not prefs:
|
||||
# Create default preferences
|
||||
prefs = UserNotificationPreference(user_id=current_user.id)
|
||||
db.add(prefs)
|
||||
db.commit()
|
||||
db.refresh(prefs)
|
||||
|
||||
return {
|
||||
"vehicle_recommended": prefs.vehicle_recommended,
|
||||
"shipping_update": prefs.shipping_update,
|
||||
"payment_confirmed": prefs.payment_confirmed,
|
||||
"withdrawal_processed": prefs.withdrawal_processed,
|
||||
"dealer_status": prefs.dealer_status,
|
||||
"share_purchased": prefs.share_purchased,
|
||||
"referral_reward": prefs.referral_reward,
|
||||
"inquiry_reply": prefs.inquiry_reply,
|
||||
"system_announcements": prefs.system_announcements,
|
||||
"push_enabled": prefs.push_enabled,
|
||||
"email_enabled": prefs.email_enabled,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/preferences")
|
||||
def update_notification_preferences(
|
||||
preferences: NotificationPreferenceUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update user's notification preferences"""
|
||||
prefs = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not prefs:
|
||||
prefs = UserNotificationPreference(user_id=current_user.id)
|
||||
db.add(prefs)
|
||||
|
||||
# Update preferences
|
||||
for field, value in preferences.dict(exclude_none=True).items():
|
||||
setattr(prefs, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(prefs)
|
||||
|
||||
return {"message": "Preferences updated successfully"}
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/stats")
|
||||
def admin_get_push_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
):
|
||||
"""Get push notification statistics (Admin only)"""
|
||||
total_subscriptions = db.query(PushSubscription).filter(
|
||||
PushSubscription.is_active == True
|
||||
).count()
|
||||
|
||||
users_with_push = db.query(PushSubscription.user_id).filter(
|
||||
PushSubscription.is_active == True
|
||||
).distinct().count()
|
||||
|
||||
return {
|
||||
"total_subscriptions": total_subscriptions,
|
||||
"users_with_push": users_with_push
|
||||
}
|
||||
|
||||
|
||||
# Helper function to send push notification (called from other modules)
|
||||
def send_push_notification(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
title: str,
|
||||
body: str,
|
||||
url: str = None,
|
||||
notification_type: str = "system"
|
||||
):
|
||||
"""
|
||||
Send push notification to a user.
|
||||
In production, this would use pywebpush to actually send the notification.
|
||||
"""
|
||||
# Check user preferences
|
||||
prefs = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == user_id
|
||||
).first()
|
||||
|
||||
if prefs and not prefs.push_enabled:
|
||||
return False
|
||||
|
||||
# Check specific notification type preference
|
||||
if prefs:
|
||||
type_pref_map = {
|
||||
"vehicle_recommended": prefs.vehicle_recommended,
|
||||
"shipping_update": prefs.shipping_update,
|
||||
"payment_confirmed": prefs.payment_confirmed,
|
||||
"withdrawal_processed": prefs.withdrawal_processed,
|
||||
"dealer_approved": prefs.dealer_status,
|
||||
"dealer_rejected": prefs.dealer_status,
|
||||
"share_purchased": prefs.share_purchased,
|
||||
"referral_reward": prefs.referral_reward,
|
||||
"inquiry_reply": prefs.inquiry_reply,
|
||||
"system": prefs.system_announcements,
|
||||
}
|
||||
if notification_type in type_pref_map and not type_pref_map[notification_type]:
|
||||
return False
|
||||
|
||||
# Get user's active subscriptions
|
||||
subscriptions = db.query(PushSubscription).filter(
|
||||
PushSubscription.user_id == user_id,
|
||||
PushSubscription.is_active == True
|
||||
).all()
|
||||
|
||||
if not subscriptions:
|
||||
return False
|
||||
|
||||
# In production, use pywebpush to send notifications
|
||||
# For now, we just log and return success
|
||||
# Example with pywebpush:
|
||||
# from pywebpush import webpush, WebPushException
|
||||
# for sub in subscriptions:
|
||||
# try:
|
||||
# webpush(
|
||||
# subscription_info={
|
||||
# "endpoint": sub.endpoint,
|
||||
# "keys": {
|
||||
# "p256dh": sub.p256dh_key,
|
||||
# "auth": sub.auth_key
|
||||
# }
|
||||
# },
|
||||
# data=json.dumps({
|
||||
# "title": title,
|
||||
# "body": body,
|
||||
# "url": url
|
||||
# }),
|
||||
# vapid_private_key=VAPID_PRIVATE_KEY,
|
||||
# vapid_claims={"sub": "mailto:admin@autosellcar.com"}
|
||||
# )
|
||||
# sub.last_used_at = datetime.utcnow()
|
||||
# except WebPushException as ex:
|
||||
# if ex.response and ex.response.status_code == 410:
|
||||
# sub.is_active = False
|
||||
# db.commit()
|
||||
|
||||
return True
|
||||
192
backend/app/api/referral.py
Normal file
192
backend/app/api/referral.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func as sql_func
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import User, ReferralReward, SystemSettings
|
||||
from ..schemas import (
|
||||
ReferralRewardResponse, ReferralStats,
|
||||
ReferralSettingsResponse, ReferralSettingsUpdate,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import notify_referral_reward
|
||||
|
||||
router = APIRouter(prefix="/referral", tags=["referral"])
|
||||
|
||||
|
||||
def get_referral_settings(db: Session) -> SystemSettings:
|
||||
"""Get or create system settings"""
|
||||
settings = db.query(SystemSettings).first()
|
||||
if not settings:
|
||||
settings = SystemSettings()
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def create_referral_reward(
|
||||
referrer_id: int,
|
||||
referred_user_id: int,
|
||||
payment_amount: float,
|
||||
db: Session
|
||||
):
|
||||
"""Create a referral reward when a referred user makes a payment"""
|
||||
settings = get_referral_settings(db)
|
||||
|
||||
# Check if referral rewards are enabled
|
||||
if not settings.referral_reward_enabled:
|
||||
return None
|
||||
|
||||
# Check if this is a one_time reward and already exists
|
||||
if settings.referral_reward_type == "one_time":
|
||||
existing = db.query(ReferralReward).filter(
|
||||
ReferralReward.referrer_id == referrer_id,
|
||||
ReferralReward.referred_user_id == referred_user_id
|
||||
).first()
|
||||
if existing:
|
||||
return None # Already gave reward for this referral
|
||||
|
||||
# Calculate reward amount
|
||||
reward_amount = payment_amount * (settings.referral_reward_percent / 100)
|
||||
|
||||
# Create reward record
|
||||
reward = ReferralReward(
|
||||
referrer_id=referrer_id,
|
||||
referred_user_id=referred_user_id,
|
||||
payment_amount=payment_amount,
|
||||
reward_amount=reward_amount,
|
||||
status="credited", # Auto-credit for simplicity
|
||||
credited_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(reward)
|
||||
db.commit()
|
||||
db.refresh(reward)
|
||||
|
||||
# Send notification to referrer
|
||||
referred_user = db.query(User).filter(User.id == referred_user_id).first()
|
||||
referred_name = referred_user.name or referred_user.email if referred_user else "회원"
|
||||
notify_referral_reward(db, referrer_id, reward_amount, referred_name)
|
||||
|
||||
return reward
|
||||
|
||||
|
||||
@router.get("/my-link")
|
||||
def get_my_referral_link(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user's referral link/code"""
|
||||
return {
|
||||
"referral_code": current_user.referral_code,
|
||||
"referral_link": f"/register?ref={current_user.referral_code}"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/my-rewards", response_model=List[ReferralRewardResponse])
|
||||
def get_my_rewards(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's referral rewards"""
|
||||
rewards = db.query(ReferralReward).filter(
|
||||
ReferralReward.referrer_id == current_user.id
|
||||
).order_by(ReferralReward.created_at.desc()).all()
|
||||
|
||||
return rewards
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ReferralStats)
|
||||
def get_referral_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get referral statistics for current user"""
|
||||
# Get all rewards where user is the referrer
|
||||
rewards = db.query(ReferralReward).filter(
|
||||
ReferralReward.referrer_id == current_user.id
|
||||
).all()
|
||||
|
||||
# Count unique referred users
|
||||
referred_users = db.query(sql_func.count(sql_func.distinct(ReferralReward.referred_user_id))).filter(
|
||||
ReferralReward.referrer_id == current_user.id
|
||||
).scalar() or 0
|
||||
|
||||
total_rewards_earned = sum(r.reward_amount for r in rewards)
|
||||
total_rewards_credited = sum(r.reward_amount for r in rewards if r.status == "credited")
|
||||
total_rewards_pending = sum(r.reward_amount for r in rewards if r.status == "pending")
|
||||
total_withdrawn = sum(r.reward_amount for r in rewards if r.status == "withdrawn")
|
||||
|
||||
return ReferralStats(
|
||||
total_referrals=referred_users,
|
||||
total_rewards_earned=total_rewards_earned,
|
||||
total_rewards_credited=total_rewards_credited,
|
||||
total_rewards_pending=total_rewards_pending,
|
||||
available_for_withdrawal=total_rewards_credited - total_withdrawn
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=ReferralSettingsResponse)
|
||||
def get_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get referral settings (public endpoint)"""
|
||||
settings = get_referral_settings(db)
|
||||
|
||||
return ReferralSettingsResponse(
|
||||
referral_reward_enabled=settings.referral_reward_enabled,
|
||||
referral_reward_percent=settings.referral_reward_percent,
|
||||
referral_reward_type=settings.referral_reward_type
|
||||
)
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.put("/admin/settings", response_model=ReferralSettingsResponse)
|
||||
def update_settings(
|
||||
update_data: ReferralSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Update referral settings"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
settings = get_referral_settings(db)
|
||||
|
||||
if update_data.referral_reward_enabled is not None:
|
||||
settings.referral_reward_enabled = update_data.referral_reward_enabled
|
||||
|
||||
if update_data.referral_reward_percent is not None:
|
||||
if update_data.referral_reward_percent < 0 or update_data.referral_reward_percent > 100:
|
||||
raise HTTPException(status_code=400, detail="Reward percent must be between 0 and 100")
|
||||
settings.referral_reward_percent = update_data.referral_reward_percent
|
||||
|
||||
if update_data.referral_reward_type is not None:
|
||||
if update_data.referral_reward_type not in ["one_time", "recurring"]:
|
||||
raise HTTPException(status_code=400, detail="Reward type must be 'one_time' or 'recurring'")
|
||||
settings.referral_reward_type = update_data.referral_reward_type
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
|
||||
return ReferralSettingsResponse(
|
||||
referral_reward_enabled=settings.referral_reward_enabled,
|
||||
referral_reward_percent=settings.referral_reward_percent,
|
||||
referral_reward_type=settings.referral_reward_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/all-rewards", response_model=List[ReferralRewardResponse])
|
||||
def admin_get_all_rewards(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all referral rewards"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
rewards = db.query(ReferralReward).order_by(
|
||||
ReferralReward.created_at.desc()
|
||||
).limit(100).all()
|
||||
|
||||
return rewards
|
||||
72
backend/app/api/settings.py
Normal file
72
backend/app/api/settings.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.settings import SystemSettings
|
||||
from ..schemas.settings import SystemSettingsUpdate, SystemSettingsResponse
|
||||
from .auth import get_current_user
|
||||
from ..models import User
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""관리자 권한 확인 (임시: 모든 로그인 사용자 허용)"""
|
||||
return current_user
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session) -> SystemSettings:
|
||||
"""시스템 설정 조회 또는 기본값 생성"""
|
||||
settings = db.query(SystemSettings).first()
|
||||
if not settings:
|
||||
settings = SystemSettings(
|
||||
search_page_size=20,
|
||||
korea_margin_percent=5.0,
|
||||
mongolia_margin_percent=5.0,
|
||||
cc_per_usdc=1, # 1 USD = 1 CC
|
||||
cc_per_view=1, # 차량 상세 조회 시 1 CC
|
||||
cars_per_cc=3, # 1 CC = 3 recommended vehicles per request
|
||||
cc_signup_bonus=3, # 3 CC free on signup
|
||||
cache_ttl_hours=2,
|
||||
container_logistics_usd=3600,
|
||||
shoring_cost_usd=300,
|
||||
)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
# ==================== Public Endpoints ====================
|
||||
|
||||
@router.get("/", response_model=SystemSettingsResponse)
|
||||
def get_system_settings(db: Session = Depends(get_db)):
|
||||
"""시스템 설정 조회 (Public)"""
|
||||
return get_or_create_settings(db)
|
||||
|
||||
|
||||
@router.get("/search-page-size")
|
||||
def get_search_page_size(db: Session = Depends(get_db)):
|
||||
"""검색 결과 페이지 크기 조회 (Public)"""
|
||||
settings = get_or_create_settings(db)
|
||||
return {"search_page_size": settings.search_page_size}
|
||||
|
||||
|
||||
# ==================== Admin Endpoints ====================
|
||||
|
||||
@router.put("/", response_model=SystemSettingsResponse)
|
||||
def update_system_settings(
|
||||
settings_data: SystemSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""시스템 설정 수정 (Admin)"""
|
||||
settings = get_or_create_settings(db)
|
||||
|
||||
update_data = settings_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(settings, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
1018
backend/app/api/translations.py
Normal file
1018
backend/app/api/translations.py
Normal file
File diff suppressed because it is too large
Load Diff
385
backend/app/api/vehicle_requests.py
Normal file
385
backend/app/api/vehicle_requests.py
Normal file
@@ -0,0 +1,385 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import VehicleRequest, RequestVehicle, PurchasedVehicle, User, DealerInfo, SystemSettings
|
||||
from ..schemas import (
|
||||
VehicleRequestCreate, VehicleRequestResponse,
|
||||
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
|
||||
PurchasedVehicleCreate, PurchasedVehicleResponse, PurchasedVehicleUpdateStatus,
|
||||
VehicleRequestWithVehicles,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import notify_vehicle_recommended, notify_shipping_update
|
||||
|
||||
|
||||
def get_system_settings(db: Session) -> SystemSettings:
|
||||
"""Get or create system settings"""
|
||||
settings = db.query(SystemSettings).first()
|
||||
if not settings:
|
||||
settings = SystemSettings()
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
def calculate_dealer_commission(vehicle_price_krw: int, db: Session) -> tuple:
|
||||
"""Calculate dealer and platform commission based on Mongolia margin"""
|
||||
settings = get_system_settings(db)
|
||||
|
||||
# Calculate Mongolia margin (vehicle price * margin percent)
|
||||
mongolia_margin = vehicle_price_krw * (settings.mongolia_margin_percent / 100)
|
||||
|
||||
# 50/50 split between dealer and platform
|
||||
dealer_commission = int(mongolia_margin * 0.5)
|
||||
platform_commission = int(mongolia_margin * 0.5)
|
||||
|
||||
return dealer_commission, platform_commission
|
||||
|
||||
router = APIRouter(prefix="/vehicle-requests", tags=["vehicle-requests"])
|
||||
|
||||
# Development mode - skip 24 hour wait
|
||||
DEV_MODE = True
|
||||
|
||||
|
||||
# =====================
|
||||
# User Endpoints
|
||||
# =====================
|
||||
|
||||
QUOTE_REQUEST_COST = 1.0 # 1 CC for quote request submission
|
||||
|
||||
|
||||
@router.post("/", response_model=VehicleRequestResponse)
|
||||
def create_request(
|
||||
request_data: VehicleRequestCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new vehicle search request (costs 1 CC)"""
|
||||
# Check if user has enough CC
|
||||
if (current_user.cc_balance or 0) < QUOTE_REQUEST_COST:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Insufficient CC balance. You need {QUOTE_REQUEST_COST} CC to submit a vehicle request. Current balance: {current_user.cc_balance or 0}"
|
||||
)
|
||||
|
||||
# Deduct CC from user's balance
|
||||
current_user.cc_balance = (current_user.cc_balance or 0) - QUOTE_REQUEST_COST
|
||||
|
||||
# Create the request
|
||||
request = VehicleRequest(
|
||||
user_id=current_user.id,
|
||||
cc_paid=QUOTE_REQUEST_COST,
|
||||
**request_data.model_dump()
|
||||
)
|
||||
db.add(request)
|
||||
db.commit()
|
||||
db.refresh(request)
|
||||
return request
|
||||
|
||||
|
||||
@router.get("/my-requests", response_model=List[VehicleRequestWithVehicles])
|
||||
def get_my_requests(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's vehicle requests with approved vehicles"""
|
||||
requests = db.query(VehicleRequest).filter(
|
||||
VehicleRequest.user_id == current_user.id
|
||||
).order_by(VehicleRequest.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for req in requests:
|
||||
# In dev mode, show all approved vehicles immediately
|
||||
# In production, only show after 24 hours
|
||||
if DEV_MODE or (req.created_at and datetime.utcnow() - req.created_at > timedelta(hours=24)):
|
||||
approved_vehicles = [v for v in req.recommended_vehicles if v.is_approved]
|
||||
else:
|
||||
approved_vehicles = []
|
||||
|
||||
result.append(VehicleRequestWithVehicles(
|
||||
request=VehicleRequestResponse.model_validate(req),
|
||||
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in approved_vehicles]
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =====================
|
||||
# Purchased Vehicles (Find My Car)
|
||||
# =====================
|
||||
|
||||
@router.get("/purchased", response_model=List[PurchasedVehicleResponse])
|
||||
def get_purchased_vehicles(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's purchased vehicles with shipping status"""
|
||||
vehicles = db.query(PurchasedVehicle).filter(
|
||||
PurchasedVehicle.user_id == current_user.id
|
||||
).order_by(PurchasedVehicle.purchased_at.desc()).all()
|
||||
return vehicles
|
||||
|
||||
|
||||
# =====================
|
||||
# Admin Endpoints
|
||||
# =====================
|
||||
|
||||
@router.get("/admin/list", response_model=List[VehicleRequestResponse])
|
||||
def admin_get_all_requests(
|
||||
status: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Get all vehicle requests"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(VehicleRequest)
|
||||
if status:
|
||||
query = query.filter(VehicleRequest.status == status)
|
||||
|
||||
requests = query.order_by(VehicleRequest.created_at.desc()).all()
|
||||
return requests
|
||||
|
||||
|
||||
@router.get("/admin/{request_id}", response_model=VehicleRequestWithVehicles)
|
||||
def admin_get_request_detail(
|
||||
request_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Get request detail with all recommended vehicles"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
return VehicleRequestWithVehicles(
|
||||
request=VehicleRequestResponse.model_validate(request),
|
||||
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in request.recommended_vehicles]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/{request_id}/vehicles", response_model=RequestVehicleResponse)
|
||||
def admin_add_vehicle(
|
||||
request_id: int,
|
||||
vehicle_data: RequestVehicleCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Add a vehicle to a request"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
vehicle = RequestVehicle(
|
||||
request_id=request_id,
|
||||
car_data=vehicle_data.car_data,
|
||||
is_approved=vehicle_data.is_approved,
|
||||
approved_at=datetime.utcnow() if vehicle_data.is_approved else None
|
||||
)
|
||||
db.add(vehicle)
|
||||
|
||||
# Update request status
|
||||
request.status = "reviewed"
|
||||
request.admin_reviewed_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
@router.post("/admin/{request_id}/approve-vehicles")
|
||||
def admin_approve_vehicles(
|
||||
request_id: int,
|
||||
approval: RequestVehicleApprove,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Approve multiple vehicles for a request"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
vehicles = db.query(RequestVehicle).filter(
|
||||
RequestVehicle.request_id == request_id,
|
||||
RequestVehicle.id.in_(approval.vehicle_ids)
|
||||
).all()
|
||||
|
||||
for vehicle in vehicles:
|
||||
vehicle.is_approved = True
|
||||
vehicle.approved_at = datetime.utcnow()
|
||||
|
||||
# Update request status
|
||||
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
|
||||
if request:
|
||||
request.status = "completed"
|
||||
request.admin_reviewed_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
# Send notification to user
|
||||
if request and len(vehicles) > 0:
|
||||
notify_vehicle_recommended(db, request.user_id, request_id, len(vehicles))
|
||||
|
||||
return {"message": f"Approved {len(vehicles)} vehicles"}
|
||||
|
||||
|
||||
@router.put("/admin/{request_id}/status")
|
||||
def admin_update_request_status(
|
||||
request_id: int,
|
||||
new_status: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Update request status"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
request.status = new_status
|
||||
request.admin_reviewed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {"message": "Status updated"}
|
||||
|
||||
|
||||
@router.delete("/admin/{request_id}/vehicles/{vehicle_id}")
|
||||
def admin_delete_vehicle(
|
||||
request_id: int,
|
||||
vehicle_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Delete a recommended vehicle from a request"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
vehicle = db.query(RequestVehicle).filter(
|
||||
RequestVehicle.id == vehicle_id,
|
||||
RequestVehicle.request_id == request_id
|
||||
).first()
|
||||
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
db.delete(vehicle)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Vehicle deleted successfully"}
|
||||
|
||||
|
||||
# =====================
|
||||
# Admin: Purchased Vehicles Management
|
||||
# =====================
|
||||
|
||||
@router.post("/admin/purchased", response_model=PurchasedVehicleResponse)
|
||||
def admin_create_purchased(
|
||||
vehicle_data: PurchasedVehicleCreate,
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Create a purchased vehicle record"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Calculate dealer commission if dealer is selected
|
||||
dealer_commission_krw = 0
|
||||
platform_commission_krw = 0
|
||||
|
||||
if vehicle_data.selected_dealer_id:
|
||||
# Verify dealer exists and is active
|
||||
dealer_info = db.query(DealerInfo).filter(
|
||||
DealerInfo.id == vehicle_data.selected_dealer_id,
|
||||
DealerInfo.is_active == True
|
||||
).first()
|
||||
|
||||
if not dealer_info:
|
||||
raise HTTPException(status_code=400, detail="Selected dealer not found or inactive")
|
||||
|
||||
# Calculate commissions
|
||||
dealer_commission_krw, platform_commission_krw = calculate_dealer_commission(
|
||||
vehicle_data.vehicle_price_krw, db
|
||||
)
|
||||
|
||||
# Credit commission to dealer's account
|
||||
dealer_info.total_commission_earned += dealer_commission_krw
|
||||
|
||||
vehicle = PurchasedVehicle(
|
||||
user_id=user_id,
|
||||
car_name=vehicle_data.car_name,
|
||||
car_data=vehicle_data.car_data,
|
||||
car_image=vehicle_data.car_image,
|
||||
vehicle_price_krw=vehicle_data.vehicle_price_krw,
|
||||
domestic_cost_krw=vehicle_data.domestic_cost_krw,
|
||||
shipping_cost_usd=vehicle_data.shipping_cost_usd,
|
||||
total_cost_krw=vehicle_data.total_cost_krw,
|
||||
car_type=vehicle_data.car_type,
|
||||
selected_dealer_id=vehicle_data.selected_dealer_id,
|
||||
dealer_commission_krw=dealer_commission_krw,
|
||||
platform_commission_krw=platform_commission_krw,
|
||||
)
|
||||
db.add(vehicle)
|
||||
db.commit()
|
||||
db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
@router.get("/admin/purchased/all", response_model=List[PurchasedVehicleResponse])
|
||||
def admin_get_all_purchased(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Get all purchased vehicles"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
vehicles = db.query(PurchasedVehicle).order_by(PurchasedVehicle.purchased_at.desc()).all()
|
||||
return vehicles
|
||||
|
||||
|
||||
@router.put("/admin/purchased/{vehicle_id}/status", response_model=PurchasedVehicleResponse)
|
||||
def admin_update_shipping_status(
|
||||
vehicle_id: int,
|
||||
status_update: PurchasedVehicleUpdateStatus,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin: Update shipping status of a purchased vehicle"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
vehicle = db.query(PurchasedVehicle).filter(PurchasedVehicle.id == vehicle_id).first()
|
||||
if not vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
vehicle.shipping_status = status_update.shipping_status
|
||||
vehicle.status_updated_at = datetime.utcnow()
|
||||
|
||||
if status_update.current_location:
|
||||
vehicle.current_location = status_update.current_location
|
||||
if status_update.estimated_arrival:
|
||||
vehicle.estimated_arrival = status_update.estimated_arrival
|
||||
|
||||
if status_update.shipping_status == 7: # Delivered (배송완료)
|
||||
vehicle.delivered_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(vehicle)
|
||||
|
||||
# Send notification to user about shipping update
|
||||
notify_shipping_update(db, vehicle.user_id, vehicle.id, status_update.shipping_status, vehicle.car_name)
|
||||
|
||||
return vehicle
|
||||
286
backend/app/api/vehicle_share.py
Normal file
286
backend/app/api/vehicle_share.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import User, VehicleShare, ShareReward, RequestVehicle
|
||||
from ..models.vehicle_share import generate_share_code
|
||||
from ..schemas import (
|
||||
VehicleShareCreate, VehicleShareResponse,
|
||||
ShareRewardResponse, ShareRewardSummary,
|
||||
)
|
||||
from .auth import get_current_user, get_current_user_optional
|
||||
from .notification import notify_share_purchased
|
||||
|
||||
router = APIRouter(prefix="/share", tags=["vehicle-share"])
|
||||
|
||||
# Tax rate for rewards (3.3% withholding tax in Korea)
|
||||
TAX_RATE = 0.033
|
||||
# Reward percentage (90% of markup goes to sharer)
|
||||
REWARD_RATE = 0.90
|
||||
|
||||
|
||||
@router.post("/create", response_model=VehicleShareResponse)
|
||||
def create_share(
|
||||
share_data: VehicleShareCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a shareable link for a vehicle with optional price markup"""
|
||||
# Get the request vehicle
|
||||
request_vehicle = db.query(RequestVehicle).filter(
|
||||
RequestVehicle.id == share_data.request_vehicle_id
|
||||
).first()
|
||||
|
||||
if not request_vehicle:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
# Check if user owns this request (through VehicleRequest)
|
||||
if request_vehicle.vehicle_request.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="You can only share vehicles from your own requests")
|
||||
|
||||
# Check if vehicle is approved
|
||||
if not request_vehicle.is_approved:
|
||||
raise HTTPException(status_code=400, detail="Only approved vehicles can be shared")
|
||||
|
||||
# Generate unique share code
|
||||
share_code = generate_share_code()
|
||||
while db.query(VehicleShare).filter(VehicleShare.share_code == share_code).first():
|
||||
share_code = generate_share_code()
|
||||
|
||||
# Calculate prices
|
||||
original_price = request_vehicle.price_krw or 0
|
||||
markup = share_data.markup_amount_krw if share_data.markup_amount_krw > 0 else 0
|
||||
shared_price = original_price + markup
|
||||
|
||||
# Create share
|
||||
vehicle_share = VehicleShare(
|
||||
user_id=current_user.id,
|
||||
request_vehicle_id=share_data.request_vehicle_id,
|
||||
share_code=share_code,
|
||||
original_price_krw=original_price,
|
||||
markup_amount_krw=markup,
|
||||
shared_price_krw=shared_price,
|
||||
)
|
||||
|
||||
db.add(vehicle_share)
|
||||
db.commit()
|
||||
db.refresh(vehicle_share)
|
||||
|
||||
return vehicle_share
|
||||
|
||||
|
||||
@router.get("/my-shares", response_model=List[VehicleShareResponse])
|
||||
def get_my_shares(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all vehicle shares created by current user"""
|
||||
shares = db.query(VehicleShare).filter(
|
||||
VehicleShare.user_id == current_user.id
|
||||
).order_by(VehicleShare.created_at.desc()).all()
|
||||
|
||||
return shares
|
||||
|
||||
|
||||
@router.get("/my-rewards", response_model=List[ShareRewardResponse])
|
||||
def get_my_rewards(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all rewards earned from vehicle shares"""
|
||||
rewards = db.query(ShareReward).filter(
|
||||
ShareReward.user_id == current_user.id
|
||||
).order_by(ShareReward.created_at.desc()).all()
|
||||
|
||||
return rewards
|
||||
|
||||
|
||||
@router.get("/my-rewards/summary", response_model=ShareRewardSummary)
|
||||
def get_rewards_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get summary of share rewards"""
|
||||
rewards = db.query(ShareReward).filter(
|
||||
ShareReward.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
total_rewards = sum(r.net_amount for r in rewards)
|
||||
total_withdrawn = sum(r.net_amount for r in rewards if r.status == "withdrawn")
|
||||
pending = sum(r.net_amount for r in rewards if r.status == "pending")
|
||||
approved = sum(r.net_amount for r in rewards if r.status == "approved")
|
||||
|
||||
return ShareRewardSummary(
|
||||
total_rewards=total_rewards,
|
||||
total_withdrawn=total_withdrawn,
|
||||
pending_amount=pending,
|
||||
available_for_withdrawal=approved,
|
||||
reward_count=len(rewards)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{share_code}")
|
||||
def get_shared_vehicle(
|
||||
share_code: str,
|
||||
current_user: User = Depends(get_current_user_optional),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get shared vehicle details (public endpoint)"""
|
||||
share = db.query(VehicleShare).filter(
|
||||
VehicleShare.share_code == share_code
|
||||
).first()
|
||||
|
||||
if not share:
|
||||
raise HTTPException(status_code=404, detail="Shared vehicle not found")
|
||||
|
||||
# Increment view count
|
||||
share.view_count += 1
|
||||
db.commit()
|
||||
|
||||
# Get vehicle details
|
||||
vehicle = share.request_vehicle
|
||||
|
||||
return {
|
||||
"share": {
|
||||
"id": share.id,
|
||||
"share_code": share.share_code,
|
||||
"shared_price_krw": share.shared_price_krw,
|
||||
"original_price_krw": share.original_price_krw,
|
||||
"markup_amount_krw": share.markup_amount_krw,
|
||||
"view_count": share.view_count,
|
||||
"is_purchased": share.is_purchased,
|
||||
"created_at": share.created_at,
|
||||
},
|
||||
"vehicle": {
|
||||
"id": vehicle.id,
|
||||
"car_id": vehicle.car_id,
|
||||
"maker": vehicle.maker,
|
||||
"model": vehicle.model,
|
||||
"year": vehicle.year,
|
||||
"mileage": vehicle.mileage,
|
||||
"fuel_type": vehicle.fuel_type,
|
||||
"color": vehicle.color,
|
||||
"grade": vehicle.grade,
|
||||
"image_url": vehicle.image_url,
|
||||
"performance_check_url": vehicle.performance_check_url,
|
||||
"dealer_name": vehicle.dealer_name,
|
||||
"dealer_phone": vehicle.dealer_phone,
|
||||
},
|
||||
"sharer": {
|
||||
"name": share.user.name or "Anonymous",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{share_code}/purchase")
|
||||
def purchase_shared_vehicle(
|
||||
share_code: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Purchase a vehicle through a shared link"""
|
||||
share = db.query(VehicleShare).filter(
|
||||
VehicleShare.share_code == share_code
|
||||
).first()
|
||||
|
||||
if not share:
|
||||
raise HTTPException(status_code=404, detail="Shared vehicle not found")
|
||||
|
||||
if share.is_purchased:
|
||||
raise HTTPException(status_code=400, detail="This vehicle has already been purchased")
|
||||
|
||||
if share.user_id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="You cannot purchase your own shared vehicle")
|
||||
|
||||
# Mark as purchased
|
||||
share.is_purchased = True
|
||||
share.purchased_by_user_id = current_user.id
|
||||
share.purchased_at = datetime.utcnow()
|
||||
|
||||
# Create reward for the sharer (if there's markup)
|
||||
reward_net = 0
|
||||
if share.markup_amount_krw > 0:
|
||||
reward_amount = share.markup_amount_krw * REWARD_RATE # 90%
|
||||
tax_amount = reward_amount * TAX_RATE # 3.3% tax
|
||||
net_amount = reward_amount - tax_amount
|
||||
reward_net = net_amount
|
||||
|
||||
reward = ShareReward(
|
||||
user_id=share.user_id,
|
||||
vehicle_share_id=share.id,
|
||||
markup_amount=share.markup_amount_krw,
|
||||
reward_amount=reward_amount,
|
||||
tax_amount=tax_amount,
|
||||
net_amount=net_amount,
|
||||
status="pending" # Needs admin approval
|
||||
)
|
||||
db.add(reward)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Send notification to sharer about the sale
|
||||
vehicle = share.request_vehicle
|
||||
car_name = f"{vehicle.maker} {vehicle.model}" if vehicle else "차량"
|
||||
notify_share_purchased(db, share.user_id, share.id, reward_net, car_name)
|
||||
|
||||
return {
|
||||
"message": "Vehicle purchase initiated",
|
||||
"share_code": share_code,
|
||||
"price": share.shared_price_krw
|
||||
}
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/all", response_model=List[VehicleShareResponse])
|
||||
def get_all_shares(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all vehicle shares"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
shares = db.query(VehicleShare).order_by(VehicleShare.created_at.desc()).all()
|
||||
return shares
|
||||
|
||||
|
||||
@router.get("/admin/rewards", response_model=List[ShareRewardResponse])
|
||||
def get_all_rewards(
|
||||
status_filter: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all share rewards"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(ShareReward)
|
||||
if status_filter:
|
||||
query = query.filter(ShareReward.status == status_filter)
|
||||
|
||||
rewards = query.order_by(ShareReward.created_at.desc()).all()
|
||||
return rewards
|
||||
|
||||
|
||||
@router.put("/admin/rewards/{reward_id}/approve")
|
||||
def approve_reward(
|
||||
reward_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Approve a share reward for withdrawal"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
reward = db.query(ShareReward).filter(ShareReward.id == reward_id).first()
|
||||
if not reward:
|
||||
raise HTTPException(status_code=404, detail="Reward not found")
|
||||
|
||||
if reward.status != "pending":
|
||||
raise HTTPException(status_code=400, detail="Reward is not pending")
|
||||
|
||||
reward.status = "approved"
|
||||
db.commit()
|
||||
|
||||
return {"message": "Reward approved", "reward_id": reward_id}
|
||||
231
backend/app/api/verification.py
Normal file
231
backend/app/api/verification.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Verification API Endpoints
|
||||
Handles email and phone verification for users
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import User
|
||||
from ..services import verification_service
|
||||
from .auth import get_current_user, get_current_user_optional
|
||||
|
||||
router = APIRouter(prefix="/verification", tags=["verification"])
|
||||
|
||||
|
||||
# Request/Response schemas
|
||||
class SendEmailCodeRequest(BaseModel):
|
||||
email: EmailStr
|
||||
language: str = "en"
|
||||
|
||||
|
||||
class SendPhoneCodeRequest(BaseModel):
|
||||
phone: str
|
||||
language: str = "en"
|
||||
|
||||
|
||||
class VerifyCodeRequest(BaseModel):
|
||||
code: str
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
class VerificationResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class VerificationStatusResponse(BaseModel):
|
||||
email_verified: bool
|
||||
phone_verified: bool
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
# Email Verification Endpoints
|
||||
@router.post("/email/send", response_model=VerificationResponse)
|
||||
async def send_email_code(
|
||||
request: SendEmailCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_optional)
|
||||
):
|
||||
"""Send email verification code"""
|
||||
user_id = current_user.id if current_user else None
|
||||
|
||||
# If user is logged in, only allow sending to their email
|
||||
if current_user and current_user.email != request.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You can only verify your own email address"
|
||||
)
|
||||
|
||||
success, message = await verification_service.send_email_verification(
|
||||
db=db,
|
||||
email=request.email,
|
||||
user_id=user_id,
|
||||
language=request.language
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
@router.post("/email/verify", response_model=VerificationResponse)
|
||||
async def verify_email_code(
|
||||
request: VerifyCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_optional)
|
||||
):
|
||||
"""Verify email code"""
|
||||
email = request.email
|
||||
if current_user:
|
||||
email = current_user.email
|
||||
|
||||
if not email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email is required"
|
||||
)
|
||||
|
||||
success, message = verification_service.verify_code(
|
||||
db=db,
|
||||
code=request.code,
|
||||
code_type="email",
|
||||
email=email
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
# If user is logged in, mark their email as verified
|
||||
if current_user:
|
||||
verification_service.mark_email_verified(db, current_user)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
# Phone Verification Endpoints
|
||||
@router.post("/phone/send", response_model=VerificationResponse)
|
||||
async def send_phone_code(
|
||||
request: SendPhoneCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user) # Requires login
|
||||
):
|
||||
"""Send phone verification code (requires login)"""
|
||||
success, message = await verification_service.send_sms_verification(
|
||||
db=db,
|
||||
phone=request.phone,
|
||||
user_id=current_user.id,
|
||||
language=request.language
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
@router.post("/phone/verify", response_model=VerificationResponse)
|
||||
async def verify_phone_code(
|
||||
request: VerifyCodeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user) # Requires login
|
||||
):
|
||||
"""Verify phone code (requires login)"""
|
||||
if not request.phone:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Phone number is required"
|
||||
)
|
||||
|
||||
# Normalize phone number
|
||||
phone = request.phone.strip().replace(" ", "").replace("-", "")
|
||||
if not phone.startswith("+"):
|
||||
if phone.startswith("9") and len(phone) == 8:
|
||||
phone = "+976" + phone
|
||||
|
||||
success, message = verification_service.verify_code(
|
||||
db=db,
|
||||
code=request.code,
|
||||
code_type="phone",
|
||||
phone=phone
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
# Mark phone as verified
|
||||
verification_service.mark_phone_verified(db, current_user, phone)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
# Status Endpoint
|
||||
@router.get("/status", response_model=VerificationStatusResponse)
|
||||
async def get_verification_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get current user's verification status"""
|
||||
return VerificationStatusResponse(
|
||||
email_verified=current_user.email_verified or False,
|
||||
phone_verified=current_user.phone_verified or False,
|
||||
email=current_user.email,
|
||||
phone=current_user.phone
|
||||
)
|
||||
|
||||
|
||||
# Pre-registration email verification (for signup flow)
|
||||
@router.post("/email/send-preregister", response_model=VerificationResponse)
|
||||
async def send_preregister_email_code(
|
||||
request: SendEmailCodeRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Send email verification code for new registration (no login required)"""
|
||||
# Check if email is already registered
|
||||
existing = db.query(User).filter(User.email == request.email).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This email is already registered"
|
||||
)
|
||||
|
||||
success, message = await verification_service.send_email_verification(
|
||||
db=db,
|
||||
email=request.email,
|
||||
user_id=None,
|
||||
language=request.language
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
|
||||
|
||||
@router.post("/email/verify-preregister", response_model=VerificationResponse)
|
||||
async def verify_preregister_email_code(
|
||||
request: VerifyCodeRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Verify email code for new registration (no login required)"""
|
||||
if not request.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email is required"
|
||||
)
|
||||
|
||||
success, message = verification_service.verify_code(
|
||||
db=db,
|
||||
code=request.code,
|
||||
code_type="email",
|
||||
email=request.email
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||
|
||||
return VerificationResponse(success=True, message=message)
|
||||
334
backend/app/api/visitor.py
Normal file
334
backend/app/api/visitor.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Visitor Analytics API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Request, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, desc
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..database import get_db
|
||||
from ..models.visitor import VisitorLog, VisitorDailyStats
|
||||
from ..models import User
|
||||
from ..services.visitor_service import log_visit, aggregate_daily_stats
|
||||
from .auth import get_current_admin_user, get_current_user_optional
|
||||
|
||||
router = APIRouter(prefix="/visitor", tags=["Visitor Analytics"])
|
||||
|
||||
|
||||
# Pydantic schemas
|
||||
class VisitLogRequest(BaseModel):
|
||||
page_path: str
|
||||
page_title: Optional[str] = None
|
||||
referrer: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
utm_source: Optional[str] = None
|
||||
utm_medium: Optional[str] = None
|
||||
utm_campaign: Optional[str] = None
|
||||
|
||||
|
||||
class VisitorStatsResponse(BaseModel):
|
||||
total_visits: int
|
||||
unique_visitors: int
|
||||
device_breakdown: dict
|
||||
browser_breakdown: dict
|
||||
country_breakdown: dict
|
||||
|
||||
|
||||
class ChartData(BaseModel):
|
||||
labels: List[str]
|
||||
values: List[int]
|
||||
|
||||
|
||||
class TopPage(BaseModel):
|
||||
path: str
|
||||
views: int
|
||||
title: Optional[str] = None
|
||||
|
||||
|
||||
class TopReferrer(BaseModel):
|
||||
domain: str
|
||||
visits: int
|
||||
|
||||
|
||||
# Background task wrapper for async log_visit
|
||||
async def _log_visit_background(
|
||||
db: Session,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
page_path: str,
|
||||
page_title: Optional[str],
|
||||
referrer: Optional[str],
|
||||
session_id: Optional[str],
|
||||
user_id: Optional[int],
|
||||
utm_source: Optional[str],
|
||||
utm_medium: Optional[str],
|
||||
utm_campaign: Optional[str],
|
||||
):
|
||||
"""Background wrapper for log_visit"""
|
||||
try:
|
||||
await log_visit(
|
||||
db, ip, user_agent, page_path, page_title,
|
||||
referrer, session_id, user_id,
|
||||
utm_source, utm_medium, utm_campaign
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Visitor] Log visit failed: {e}")
|
||||
|
||||
|
||||
# Public endpoint for logging visits
|
||||
@router.post("/log")
|
||||
async def log_page_visit(
|
||||
visit_data: VisitLogRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||
):
|
||||
"""
|
||||
Log a page visit (called from frontend)
|
||||
"""
|
||||
# Get client IP (handle proxies)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
ip = forwarded_for.split(",")[0].strip()
|
||||
else:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
user_agent = request.headers.get("User-Agent", "")
|
||||
user_id = current_user.id if current_user else None
|
||||
|
||||
# Log visit directly (async)
|
||||
try:
|
||||
await log_visit(
|
||||
db,
|
||||
ip,
|
||||
user_agent,
|
||||
visit_data.page_path,
|
||||
visit_data.page_title,
|
||||
visit_data.referrer,
|
||||
visit_data.session_id,
|
||||
user_id,
|
||||
visit_data.utm_source,
|
||||
visit_data.utm_medium,
|
||||
visit_data.utm_campaign,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Visitor] Log visit failed: {e}")
|
||||
|
||||
return {"status": "logged"}
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/overview", response_model=VisitorStatsResponse)
|
||||
def get_visitor_overview(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get visitor statistics overview for last N days"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Total visits
|
||||
total_visits = db.query(func.count(VisitorLog.id)).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).scalar() or 0
|
||||
|
||||
# Unique visitors
|
||||
unique_visitors = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).scalar() or 0
|
||||
|
||||
# Device breakdown
|
||||
device_query = db.query(
|
||||
VisitorLog.device_type,
|
||||
func.count(VisitorLog.id)
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(VisitorLog.device_type).all()
|
||||
|
||||
device_breakdown = {d[0] or "unknown": d[1] for d in device_query}
|
||||
|
||||
# Browser breakdown
|
||||
browser_query = db.query(
|
||||
VisitorLog.browser,
|
||||
func.count(VisitorLog.id)
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(VisitorLog.browser).all()
|
||||
|
||||
browser_breakdown = {b[0] or "unknown": b[1] for b in browser_query}
|
||||
|
||||
# Country breakdown
|
||||
country_query = db.query(
|
||||
VisitorLog.country_code,
|
||||
func.count(VisitorLog.id)
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(VisitorLog.country_code).all()
|
||||
|
||||
country_breakdown = {c[0] or "unknown": c[1] for c in country_query}
|
||||
|
||||
return VisitorStatsResponse(
|
||||
total_visits=total_visits,
|
||||
unique_visitors=unique_visitors,
|
||||
device_breakdown=device_breakdown,
|
||||
browser_breakdown=browser_breakdown,
|
||||
country_breakdown=country_breakdown,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/chart/visits", response_model=ChartData)
|
||||
def get_visits_chart(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get daily visits chart data"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
||||
count = db.query(func.count(VisitorLog.id)).filter(
|
||||
func.date(VisitorLog.visited_at) == date_str
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/admin/chart/unique-visitors", response_model=ChartData)
|
||||
def get_unique_visitors_chart(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get daily unique visitors chart data"""
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
|
||||
for i in range(days - 1, -1, -1):
|
||||
date = today - timedelta(days=i)
|
||||
date_str = date.strftime("%Y-%m-%d")
|
||||
|
||||
count = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
|
||||
func.date(VisitorLog.visited_at) == date_str
|
||||
).scalar() or 0
|
||||
|
||||
labels.append(date.strftime("%m/%d"))
|
||||
values.append(count)
|
||||
|
||||
return ChartData(labels=labels, values=values)
|
||||
|
||||
|
||||
@router.get("/admin/top-pages", response_model=List[TopPage])
|
||||
def get_top_pages(
|
||||
days: int = 30,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get top visited pages"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
pages = db.query(
|
||||
VisitorLog.page_path,
|
||||
VisitorLog.page_title,
|
||||
func.count(VisitorLog.id).label("views")
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_date
|
||||
).group_by(
|
||||
VisitorLog.page_path, VisitorLog.page_title
|
||||
).order_by(
|
||||
desc("views")
|
||||
).limit(limit).all()
|
||||
|
||||
return [
|
||||
TopPage(path=p[0], title=p[1], views=p[2])
|
||||
for p in pages
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/top-referrers", response_model=List[TopReferrer])
|
||||
def get_top_referrers(
|
||||
days: int = 30,
|
||||
limit: int = 10,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get top referrer sources"""
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
referrers = db.query(
|
||||
VisitorLog.referrer_domain,
|
||||
func.count(VisitorLog.id).label("visits")
|
||||
).filter(
|
||||
and_(
|
||||
VisitorLog.visited_at >= start_date,
|
||||
VisitorLog.referrer_domain.isnot(None),
|
||||
VisitorLog.referrer_domain != ""
|
||||
)
|
||||
).group_by(
|
||||
VisitorLog.referrer_domain
|
||||
).order_by(
|
||||
desc("visits")
|
||||
).limit(limit).all()
|
||||
|
||||
return [
|
||||
TopReferrer(domain=r[0], visits=r[1])
|
||||
for r in referrers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/admin/realtime")
|
||||
def get_realtime_visitors(
|
||||
minutes: int = 5,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get visitors in the last N minutes (real-time)"""
|
||||
start_time = datetime.utcnow() - timedelta(minutes=minutes)
|
||||
|
||||
active_count = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
|
||||
VisitorLog.visited_at >= start_time
|
||||
).scalar() or 0
|
||||
|
||||
# Recent pages
|
||||
recent_pages = db.query(
|
||||
VisitorLog.page_path,
|
||||
func.count(VisitorLog.id).label("views")
|
||||
).filter(
|
||||
VisitorLog.visited_at >= start_time
|
||||
).group_by(
|
||||
VisitorLog.page_path
|
||||
).order_by(
|
||||
desc("views")
|
||||
).limit(5).all()
|
||||
|
||||
return {
|
||||
"active_visitors": active_count,
|
||||
"minutes": minutes,
|
||||
"recent_pages": [{"path": p[0], "views": p[1]} for p in recent_pages],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/admin/aggregate/{date_str}")
|
||||
def trigger_aggregation(
|
||||
date_str: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Manually trigger aggregation for a specific date (YYYY-MM-DD)"""
|
||||
result = aggregate_daily_stats(db, date_str)
|
||||
if result:
|
||||
return {"status": "success", "date": date_str}
|
||||
return {"status": "no_data", "date": date_str}
|
||||
217
backend/app/api/withdrawal.py
Normal file
217
backend/app/api/withdrawal.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func as sql_func
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import User, WithdrawalRequest, DealerInfo, ShareReward, ReferralReward
|
||||
from ..schemas import (
|
||||
WithdrawalRequestCreate, WithdrawalRequestResponse,
|
||||
WithdrawalProcess, WithdrawalBalance,
|
||||
)
|
||||
from .auth import get_current_user
|
||||
from .notification import notify_withdrawal_processed
|
||||
|
||||
router = APIRouter(prefix="/withdrawal", tags=["withdrawal"])
|
||||
|
||||
# Tax rate (3.3% withholding)
|
||||
TAX_RATE = 0.033
|
||||
|
||||
|
||||
def calculate_user_balance(user: User, db: Session) -> WithdrawalBalance:
|
||||
"""Calculate user's withdrawal balance from all sources"""
|
||||
total_earned = 0.0
|
||||
total_withdrawn = 0.0
|
||||
pending_withdrawal = 0.0
|
||||
|
||||
# Get dealer earnings if user is a dealer
|
||||
if user.is_dealer:
|
||||
dealer_info = db.query(DealerInfo).filter(DealerInfo.user_id == user.id).first()
|
||||
if dealer_info:
|
||||
total_earned += dealer_info.total_commission_earned
|
||||
total_withdrawn += dealer_info.total_withdrawn
|
||||
|
||||
# Get share rewards
|
||||
share_rewards = db.query(ShareReward).filter(
|
||||
ShareReward.user_id == user.id,
|
||||
ShareReward.status.in_(["approved", "withdrawn"])
|
||||
).all()
|
||||
|
||||
for reward in share_rewards:
|
||||
total_earned += reward.net_amount
|
||||
if reward.status == "withdrawn":
|
||||
total_withdrawn += reward.net_amount
|
||||
|
||||
# Get referral rewards
|
||||
referral_rewards = db.query(ReferralReward).filter(
|
||||
ReferralReward.referrer_id == user.id,
|
||||
ReferralReward.status.in_(["credited", "withdrawn"])
|
||||
).all()
|
||||
|
||||
for reward in referral_rewards:
|
||||
total_earned += reward.reward_amount
|
||||
if reward.status == "withdrawn":
|
||||
total_withdrawn += reward.reward_amount
|
||||
|
||||
# Get pending withdrawals
|
||||
pending_requests = db.query(WithdrawalRequest).filter(
|
||||
WithdrawalRequest.user_id == user.id,
|
||||
WithdrawalRequest.status.in_(["pending", "approved"])
|
||||
).all()
|
||||
|
||||
for req in pending_requests:
|
||||
pending_withdrawal += req.net_amount
|
||||
|
||||
available_balance = total_earned - total_withdrawn - pending_withdrawal
|
||||
|
||||
return WithdrawalBalance(
|
||||
total_earned=total_earned,
|
||||
total_withdrawn=total_withdrawn,
|
||||
pending_withdrawal=pending_withdrawal,
|
||||
available_balance=max(0, available_balance)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/balance", response_model=WithdrawalBalance)
|
||||
def get_balance(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's withdrawal balance"""
|
||||
return calculate_user_balance(current_user, db)
|
||||
|
||||
|
||||
@router.post("/request", response_model=WithdrawalRequestResponse)
|
||||
def create_withdrawal_request(
|
||||
request_data: WithdrawalRequestCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new withdrawal request"""
|
||||
# Check balance
|
||||
balance = calculate_user_balance(current_user, db)
|
||||
|
||||
if request_data.amount <= 0:
|
||||
raise HTTPException(status_code=400, detail="Amount must be positive")
|
||||
|
||||
if request_data.amount > balance.available_balance:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Insufficient balance. Available: {balance.available_balance}"
|
||||
)
|
||||
|
||||
# Minimum withdrawal amount
|
||||
MIN_WITHDRAWAL = 10 # 10 USD minimum
|
||||
if request_data.amount < MIN_WITHDRAWAL:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Minimum withdrawal amount is ${MIN_WITHDRAWAL} USD"
|
||||
)
|
||||
|
||||
# Calculate tax and net amount
|
||||
tax_amount = request_data.amount * TAX_RATE
|
||||
net_amount = request_data.amount - tax_amount
|
||||
|
||||
# Create withdrawal request
|
||||
withdrawal = WithdrawalRequest(
|
||||
user_id=current_user.id,
|
||||
amount=request_data.amount,
|
||||
tax_withheld=tax_amount,
|
||||
net_amount=net_amount,
|
||||
bank_name=request_data.bank_name,
|
||||
bank_account=request_data.bank_account,
|
||||
account_holder=request_data.account_holder,
|
||||
status="pending"
|
||||
)
|
||||
|
||||
db.add(withdrawal)
|
||||
db.commit()
|
||||
db.refresh(withdrawal)
|
||||
|
||||
return withdrawal
|
||||
|
||||
|
||||
@router.get("/my-requests", response_model=List[WithdrawalRequestResponse])
|
||||
def get_my_requests(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's withdrawal requests"""
|
||||
requests = db.query(WithdrawalRequest).filter(
|
||||
WithdrawalRequest.user_id == current_user.id
|
||||
).order_by(WithdrawalRequest.requested_at.desc()).all()
|
||||
|
||||
return requests
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@router.get("/admin/list", response_model=List[WithdrawalRequestResponse])
|
||||
def get_all_requests(
|
||||
status_filter: str = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Get all withdrawal requests"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
query = db.query(WithdrawalRequest)
|
||||
if status_filter:
|
||||
query = query.filter(WithdrawalRequest.status == status_filter)
|
||||
|
||||
requests = query.order_by(WithdrawalRequest.requested_at.desc()).all()
|
||||
return requests
|
||||
|
||||
|
||||
@router.put("/admin/{request_id}/process", response_model=WithdrawalRequestResponse)
|
||||
def process_withdrawal(
|
||||
request_id: int,
|
||||
process_data: WithdrawalProcess,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""[Admin] Process a withdrawal request"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
withdrawal = db.query(WithdrawalRequest).filter(
|
||||
WithdrawalRequest.id == request_id
|
||||
).first()
|
||||
|
||||
if not withdrawal:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
valid_statuses = ["approved", "completed", "rejected"]
|
||||
if process_data.status not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status. Must be one of: {valid_statuses}"
|
||||
)
|
||||
|
||||
# Update status
|
||||
withdrawal.status = process_data.status
|
||||
withdrawal.admin_note = process_data.admin_note
|
||||
withdrawal.processed_at = datetime.utcnow()
|
||||
|
||||
# If completed, update user's withdrawal totals
|
||||
if process_data.status == "completed":
|
||||
user = db.query(User).filter(User.id == withdrawal.user_id).first()
|
||||
|
||||
# Update dealer info if applicable
|
||||
if user.is_dealer:
|
||||
dealer_info = db.query(DealerInfo).filter(
|
||||
DealerInfo.user_id == user.id
|
||||
).first()
|
||||
if dealer_info:
|
||||
dealer_info.total_withdrawn += withdrawal.net_amount
|
||||
|
||||
# Mark related share rewards as withdrawn
|
||||
# (This is a simplified version - in production you'd track which specific rewards were withdrawn)
|
||||
|
||||
db.commit()
|
||||
db.refresh(withdrawal)
|
||||
|
||||
# Send notification to user about withdrawal status
|
||||
notify_withdrawal_processed(db, withdrawal.user_id, withdrawal.id, process_data.status, withdrawal.net_amount)
|
||||
|
||||
return withdrawal
|
||||
Reference in New Issue
Block a user