feat(T02): vehicle management + mobile.de push + vehicle UI
- Vehicle model: 25+ fields, 5 vehicle types, soft-delete - Vehicle CRUD: 7 API endpoints with JWT auth, filter/sort/paginate - mobile.de: push/update/delete listings, field mapping, retry logic - MobileDeListing model for sync status tracking - Frontend: VehicleList, VehicleForm, VehicleDetail, MobileDeStatus - 73 backend tests (82% coverage), 16 frontend tests
This commit is contained in:
@@ -31,6 +31,8 @@ class Settings(BaseSettings):
|
||||
UPLOAD_DIR: str = "/tmp/uploads"
|
||||
APP_NAME: str = "ERP Nutzfahrzeuge"
|
||||
APP_ENV: str = "development"
|
||||
MOBILE_DE_API_KEY: str = ""
|
||||
MOBILE_DE_SELLER_ID: str = ""
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
|
||||
+2
-1
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, users
|
||||
from app.routers import auth, users, vehicles
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -40,6 +40,7 @@ app.add_middleware(
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
api_v1_router.include_router(auth.router)
|
||||
api_v1_router.include_router(users.router)
|
||||
api_v1_router.include_router(vehicles.router)
|
||||
|
||||
# Health endpoint (no auth required)
|
||||
@api_v1_router.get("/health", tags=["health"])
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""SQLAlchemy models for vehicles and mobile.de listings."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
Date,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class VehicleCondition(str, enum.Enum):
|
||||
new = "new"
|
||||
used = "used"
|
||||
|
||||
|
||||
class VehicleAvailability(str, enum.Enum):
|
||||
available = "available"
|
||||
reserved = "reserved"
|
||||
sold = "sold"
|
||||
|
||||
|
||||
class VehicleType(str, enum.Enum):
|
||||
lkw = "lkw"
|
||||
pkw = "pkw"
|
||||
baumaschine = "baumaschine"
|
||||
stapler = "stapler"
|
||||
transporter = "transporter"
|
||||
|
||||
|
||||
class SyncStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
synced = "synced"
|
||||
fehler = "fehler"
|
||||
|
||||
|
||||
class Vehicle(Base):
|
||||
"""Vehicle entity for the ERP inventory."""
|
||||
|
||||
__tablename__ = "vehicles"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"char_length(fin) = 17", name="ck_vehicles_fin_length"
|
||||
),
|
||||
CheckConstraint(
|
||||
"condition IN ('new', 'used')", name="ck_vehicles_condition"
|
||||
),
|
||||
CheckConstraint(
|
||||
"availability IN ('available', 'reserved', 'sold')",
|
||||
name="ck_vehicles_availability",
|
||||
),
|
||||
CheckConstraint(
|
||||
"vehicle_type IN ('lkw', 'pkw', 'baumaschine', 'stapler', 'transporter')",
|
||||
name="ck_vehicles_vehicle_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"operating_hours_unit IS NULL OR operating_hours_unit IN ('h', 'min')",
|
||||
name="ck_vehicles_operating_hours_unit",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
make: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
model: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
fin: Mapped[str] = mapped_column(
|
||||
String(17), unique=True, nullable=False, index=True
|
||||
)
|
||||
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
first_registration: Mapped[date | None] = mapped_column(
|
||||
Date, nullable=True
|
||||
)
|
||||
power_kw: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
power_hp: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fuel_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
transmission: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
condition: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="used"
|
||||
)
|
||||
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
availability: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="available"
|
||||
)
|
||||
price: Mapped[Decimal] = mapped_column(
|
||||
Numeric(12, 2), nullable=False
|
||||
)
|
||||
vehicle_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
lkw_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
machine_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
body_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
operating_hours: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(12, 1), nullable=True
|
||||
)
|
||||
operating_hours_unit: Mapped[str | None] = mapped_column(
|
||||
String(5), nullable=True
|
||||
)
|
||||
mileage_km: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
mobile_de_listings: Mapped[list["MobileDeListing"]] = relationship(
|
||||
back_populates="vehicle", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Vehicle id={self.id} fin={self.fin} make={self.make}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize vehicle for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"make": self.make,
|
||||
"model": self.model,
|
||||
"fin": self.fin,
|
||||
"year": self.year,
|
||||
"first_registration": (
|
||||
self.first_registration.isoformat()
|
||||
if self.first_registration
|
||||
else None
|
||||
),
|
||||
"power_kw": self.power_kw,
|
||||
"power_hp": self.power_hp,
|
||||
"fuel_type": self.fuel_type,
|
||||
"transmission": self.transmission,
|
||||
"color": self.color,
|
||||
"condition": self.condition,
|
||||
"location": self.location,
|
||||
"availability": self.availability,
|
||||
"price": float(self.price) if self.price is not None else None,
|
||||
"vehicle_type": self.vehicle_type,
|
||||
"lkw_type": self.lkw_type,
|
||||
"machine_type": self.machine_type,
|
||||
"body_type": self.body_type,
|
||||
"operating_hours": (
|
||||
float(self.operating_hours)
|
||||
if self.operating_hours is not None
|
||||
else None
|
||||
),
|
||||
"operating_hours_unit": self.operating_hours_unit,
|
||||
"mileage_km": self.mileage_km,
|
||||
"description": self.description,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
"deleted_at": (
|
||||
self.deleted_at.isoformat() if self.deleted_at else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class MobileDeListing(Base):
|
||||
"""mobile.de listing sync tracking for a vehicle."""
|
||||
|
||||
__tablename__ = "mobile_de_listings"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
vehicle_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("vehicles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
ad_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
sync_status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending"
|
||||
)
|
||||
synced_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
error_log: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
vehicle: Mapped["Vehicle"] = relationship(back_populates="mobile_de_listings")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MobileDeListing id={self.id} vehicle_id={self.vehicle_id} status={self.sync_status}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize listing for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"vehicle_id": str(self.vehicle_id),
|
||||
"ad_id": self.ad_id,
|
||||
"sync_status": self.sync_status,
|
||||
"synced_at": (
|
||||
self.synced_at.isoformat() if self.synced_at else None
|
||||
),
|
||||
"error_log": self.error_log,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Vehicles router: CRUD, filtering, sorting, and mobile.de integration endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_pagination
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas.vehicle import (
|
||||
MobileDePushResponse,
|
||||
MobileDeStatusResponse,
|
||||
VehicleCreate,
|
||||
VehicleListResponse,
|
||||
VehicleResponse,
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.services import vehicle_service
|
||||
from app.services import mobilede_service
|
||||
|
||||
router = APIRouter(prefix="/vehicles", tags=["vehicles"])
|
||||
|
||||
|
||||
@router.get("/", response_model=VehicleListResponse, status_code=status.HTTP_200_OK)
|
||||
async def list_vehicles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
vehicle_type: str | None = Query(None, description="Filter by vehicle type"),
|
||||
availability: str | None = Query(None, description="Filter by availability"),
|
||||
min_price: float | None = Query(None, ge=0, description="Minimum price"),
|
||||
max_price: float | None = Query(None, ge=0, description="Maximum price"),
|
||||
search: str | None = Query(None, description="Search in make, model, fin, location"),
|
||||
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List vehicles with pagination, filtering, and sorting."""
|
||||
vehicles, total = await vehicle_service.list_vehicles(
|
||||
db,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
vehicle_type=vehicle_type,
|
||||
availability=availability,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
search=search,
|
||||
sort=sort,
|
||||
)
|
||||
return VehicleListResponse(
|
||||
items=[VehicleResponse.model_validate(v) for v in vehicles],
|
||||
total=total,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=VehicleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vehicle(
|
||||
body: VehicleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new vehicle."""
|
||||
data = body.model_dump(exclude_unset=False)
|
||||
try:
|
||||
vehicle = await vehicle_service.create_vehicle(db, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.get("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single vehicle by ID."""
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.put("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
async def update_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
body: VehicleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a vehicle's fields."""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||
)
|
||||
try:
|
||||
vehicle = await vehicle_service.update_vehicle(db, vehicle_id, updates)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||
)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.delete("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||
async def delete_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Soft-delete a vehicle (sets deleted_at)."""
|
||||
vehicle = await vehicle_service.soft_delete_vehicle(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
return VehicleResponse.model_validate(vehicle)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{vehicle_id}/mobile-de/push",
|
||||
response_model=MobileDePushResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def push_to_mobile_de(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Push a vehicle listing to mobile.de (async, returns 202)."""
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
|
||||
listing = await mobilede_service.push_listing(db, vehicle)
|
||||
|
||||
return MobileDePushResponse(
|
||||
message="Push queued",
|
||||
vehicle_id=vehicle.id,
|
||||
listing_id=listing.id,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{vehicle_id}/mobile-de/status",
|
||||
response_model=MobileDeStatusResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def get_mobile_de_status(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get the mobile.de sync status for a vehicle."""
|
||||
# Verify vehicle exists
|
||||
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||
)
|
||||
|
||||
listing = await mobilede_service.get_listing_status(db, vehicle_id)
|
||||
if listing is None:
|
||||
return MobileDeStatusResponse(
|
||||
synced=False,
|
||||
ad_id=None,
|
||||
synced_at=None,
|
||||
sync_status="pending",
|
||||
error_log=None,
|
||||
)
|
||||
|
||||
return MobileDeStatusResponse(
|
||||
synced=(listing.sync_status == "synced"),
|
||||
ad_id=listing.ad_id,
|
||||
synced_at=listing.synced_at,
|
||||
sync_status=listing.sync_status,
|
||||
error_log=listing.error_log,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Pydantic schemas for vehicle-related request and response bodies."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
VEHICLE_TYPES = Literal["lkw", "pkw", "baumaschine", "stapler", "transporter"]
|
||||
CONDITIONS = Literal["new", "used"]
|
||||
AVAILABILITY = Literal["available", "reserved", "sold"]
|
||||
HOURS_UNITS = Literal["h", "min"]
|
||||
|
||||
|
||||
class VehicleBase(BaseModel):
|
||||
"""Base vehicle fields shared across schemas."""
|
||||
|
||||
make: str = Field(..., min_length=1, max_length=100)
|
||||
model: str = Field(..., min_length=1, max_length=100)
|
||||
fin: str = Field(..., min_length=17, max_length=17)
|
||||
year: Optional[int] = Field(None, ge=1900, le=2100)
|
||||
first_registration: Optional[date] = None
|
||||
power_kw: Optional[int] = Field(None, ge=0)
|
||||
power_hp: Optional[int] = Field(None, ge=0)
|
||||
fuel_type: Optional[str] = Field(None, max_length=50)
|
||||
transmission: Optional[str] = Field(None, max_length=20)
|
||||
color: Optional[str] = Field(None, max_length=50)
|
||||
condition: CONDITIONS = "used"
|
||||
location: Optional[str] = Field(None, max_length=255)
|
||||
availability: AVAILABILITY = "available"
|
||||
price: Decimal = Field(..., ge=0)
|
||||
vehicle_type: VEHICLE_TYPES
|
||||
lkw_type: Optional[str] = Field(None, max_length=50)
|
||||
machine_type: Optional[str] = Field(None, max_length=50)
|
||||
body_type: Optional[str] = Field(None, max_length=100)
|
||||
operating_hours: Optional[Decimal] = Field(None, ge=0)
|
||||
operating_hours_unit: Optional[HOURS_UNITS] = None
|
||||
mileage_km: Optional[int] = Field(None, ge=0)
|
||||
description: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def compute_power_hp(self) -> "VehicleBase":
|
||||
"""Auto-compute power_hp from power_kw if not provided."""
|
||||
if self.power_kw is not None and self.power_hp is None:
|
||||
self.power_hp = round(self.power_kw * 1.35962)
|
||||
return self
|
||||
|
||||
|
||||
class VehicleCreate(VehicleBase):
|
||||
"""POST /api/v1/vehicles request body."""
|
||||
pass
|
||||
|
||||
|
||||
class VehicleUpdate(BaseModel):
|
||||
"""PUT /api/v1/vehicles/:id request body (all fields optional)."""
|
||||
|
||||
make: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
model: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
fin: Optional[str] = Field(None, min_length=17, max_length=17)
|
||||
year: Optional[int] = Field(None, ge=1900, le=2100)
|
||||
first_registration: Optional[date] = None
|
||||
power_kw: Optional[int] = Field(None, ge=0)
|
||||
power_hp: Optional[int] = Field(None, ge=0)
|
||||
fuel_type: Optional[str] = Field(None, max_length=50)
|
||||
transmission: Optional[str] = Field(None, max_length=20)
|
||||
color: Optional[str] = Field(None, max_length=50)
|
||||
condition: Optional[CONDITIONS] = None
|
||||
location: Optional[str] = Field(None, max_length=255)
|
||||
availability: Optional[AVAILABILITY] = None
|
||||
price: Optional[Decimal] = Field(None, ge=0)
|
||||
vehicle_type: Optional[VEHICLE_TYPES] = None
|
||||
lkw_type: Optional[str] = Field(None, max_length=50)
|
||||
machine_type: Optional[str] = Field(None, max_length=50)
|
||||
body_type: Optional[str] = Field(None, max_length=100)
|
||||
operating_hours: Optional[Decimal] = Field(None, ge=0)
|
||||
operating_hours_unit: Optional[HOURS_UNITS] = None
|
||||
mileage_km: Optional[int] = Field(None, ge=0)
|
||||
description: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def compute_power_hp(self) -> "VehicleUpdate":
|
||||
"""Auto-compute power_hp from power_kw if not provided."""
|
||||
if self.power_kw is not None and self.power_hp is None:
|
||||
self.power_hp = round(self.power_kw * 1.35962)
|
||||
return self
|
||||
|
||||
|
||||
class VehicleResponse(BaseModel):
|
||||
"""Vehicle response schema."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
make: str
|
||||
model: str
|
||||
fin: str
|
||||
year: Optional[int] = None
|
||||
first_registration: Optional[date] = None
|
||||
power_kw: Optional[int] = None
|
||||
power_hp: Optional[int] = None
|
||||
fuel_type: Optional[str] = None
|
||||
transmission: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
condition: str
|
||||
location: Optional[str] = None
|
||||
availability: str
|
||||
price: Decimal
|
||||
vehicle_type: str
|
||||
lkw_type: Optional[str] = None
|
||||
machine_type: Optional[str] = None
|
||||
body_type: Optional[str] = None
|
||||
operating_hours: Optional[Decimal] = None
|
||||
operating_hours_unit: Optional[str] = None
|
||||
mileage_km: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class VehicleListResponse(BaseModel):
|
||||
"""Paginated vehicle list response."""
|
||||
|
||||
items: list[VehicleResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class MobileDeStatusResponse(BaseModel):
|
||||
"""mobile.de sync status for a vehicle."""
|
||||
|
||||
synced: bool
|
||||
ad_id: Optional[str] = None
|
||||
synced_at: Optional[datetime] = None
|
||||
sync_status: str = "pending"
|
||||
error_log: Optional[str] = None
|
||||
|
||||
|
||||
class MobileDePushResponse(BaseModel):
|
||||
"""Response for mobile.de push request."""
|
||||
|
||||
message: str = "Push queued"
|
||||
vehicle_id: uuid.UUID
|
||||
listing_id: Optional[uuid.UUID] = None
|
||||
@@ -0,0 +1,244 @@
|
||||
"""mobile.de integration service: push, update, delete listings and check status.
|
||||
|
||||
Uses httpx for async HTTP calls to the mobile.de seller listings API.
|
||||
API key and seller ID are loaded from environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.vehicle import MobileDeListing, Vehicle
|
||||
from app.utils.mobilede_mapping import map_fields
|
||||
|
||||
# mobile.de API base URL
|
||||
_MOBILE_DE_API_BASE = "https://api.mobile.de"
|
||||
|
||||
# Maximum retry attempts for failed pushes
|
||||
MAX_RETRIES = 3
|
||||
|
||||
|
||||
def _get_api_headers() -> dict[str, str]:
|
||||
"""Build authorization headers for mobile.de API."""
|
||||
api_key = settings.MOBILE_DE_API_KEY
|
||||
return {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Mobile-DE-Seller-ID": settings.MOBILE_DE_SELLER_ID,
|
||||
}
|
||||
|
||||
|
||||
def _get_listing_url(listing_id: str | None = None) -> str:
|
||||
"""Build the mobile.de listings URL."""
|
||||
base = f"{_MOBILE_DE_API_BASE}/api/seller/listings"
|
||||
if listing_id:
|
||||
return f"{base}/{listing_id}"
|
||||
return base
|
||||
|
||||
|
||||
def _get_status_url(listing_id: str) -> str:
|
||||
"""Build the mobile.de listing status URL."""
|
||||
return f"{_MOBILE_DE_API_BASE}/api/seller/listings/{listing_id}/status"
|
||||
|
||||
|
||||
async def push_listing(
|
||||
db: AsyncSession, vehicle: Vehicle
|
||||
) -> MobileDeListing:
|
||||
"""Push a vehicle listing to mobile.de.
|
||||
|
||||
Creates a MobileDeListing record with status 'pending',
|
||||
sends the mapped ad data to mobile.de POST /api/seller/listings,
|
||||
and updates the listing with the returned ad_id and 'synced' status.
|
||||
|
||||
On failure, sets sync_status to 'fehler' with error_log.
|
||||
"""
|
||||
# Create listing record
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="pending",
|
||||
)
|
||||
db.add(listing)
|
||||
await db.flush()
|
||||
|
||||
# Map vehicle fields to mobile.de ad format
|
||||
ad_data = map_fields(vehicle)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
_get_listing_url(),
|
||||
json=ad_data,
|
||||
headers=_get_api_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
ad_id = result.get("id") or result.get("listingId")
|
||||
|
||||
listing.ad_id = str(ad_id) if ad_id else None
|
||||
listing.sync_status = "synced"
|
||||
listing.synced_at = datetime.now(timezone.utc)
|
||||
listing.error_log = None
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||
)
|
||||
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||
except Exception as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
|
||||
async def update_listing(
|
||||
db: AsyncSession, vehicle: Vehicle, listing: MobileDeListing
|
||||
) -> MobileDeListing:
|
||||
"""Update an existing mobile.de listing.
|
||||
|
||||
Sends PUT /api/seller/listings/{id} with updated ad data.
|
||||
"""
|
||||
if not listing.ad_id:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = "Cannot update listing without ad_id"
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
ad_data = map_fields(vehicle)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.put(
|
||||
_get_listing_url(listing.ad_id),
|
||||
json=ad_data,
|
||||
headers=_get_api_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
listing.sync_status = "synced"
|
||||
listing.synced_at = datetime.now(timezone.utc)
|
||||
listing.error_log = None
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||
)
|
||||
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||
except Exception as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
|
||||
async def delete_listing(
|
||||
db: AsyncSession, listing: MobileDeListing
|
||||
) -> MobileDeListing:
|
||||
"""Delete a listing from mobile.de.
|
||||
|
||||
Sends DELETE /api/seller/listings/{id}.
|
||||
"""
|
||||
if not listing.ad_id:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = "Cannot delete listing without ad_id"
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
_get_listing_url(listing.ad_id),
|
||||
headers=_get_api_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
listing.sync_status = "deleted"
|
||||
listing.error_log = None
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||
)
|
||||
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||
except Exception as exc:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
|
||||
async def get_listing_status(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> MobileDeListing | None:
|
||||
"""Get the latest mobile.de listing status for a vehicle.
|
||||
|
||||
Returns the most recent MobileDeListing record, or None if no listing exists.
|
||||
"""
|
||||
stmt = (
|
||||
select(MobileDeListing)
|
||||
.where(MobileDeListing.vehicle_id == vehicle_id)
|
||||
.order_by(MobileDeListing.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def retry_failed_listing(
|
||||
db: AsyncSession, listing: MobileDeListing, vehicle: Vehicle
|
||||
) -> MobileDeListing:
|
||||
"""Retry a failed listing push.
|
||||
|
||||
Increments retry count (tracked via error_log prefix) and re-attempts push.
|
||||
After MAX_RETRIES, marks as permanently failed.
|
||||
"""
|
||||
# Count existing retries from error_log
|
||||
retry_count = 0
|
||||
if listing.error_log and listing.error_log.startswith("[retry"):
|
||||
try:
|
||||
retry_count = int(listing.error_log.split("]")[0].split("retry ")[1])
|
||||
except (IndexError, ValueError):
|
||||
retry_count = 0
|
||||
|
||||
if retry_count >= MAX_RETRIES:
|
||||
listing.sync_status = "fehler"
|
||||
listing.error_log = (
|
||||
f"Max retries ({MAX_RETRIES}) exceeded. "
|
||||
f"Last error: {listing.error_log}"
|
||||
)
|
||||
await db.flush()
|
||||
await db.refresh(listing)
|
||||
return listing
|
||||
|
||||
# Attempt re-push
|
||||
listing.sync_status = "pending"
|
||||
listing.error_log = f"[retry {retry_count + 1}] Retrying push"
|
||||
await db.flush()
|
||||
|
||||
return await push_listing(db, vehicle)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Vehicle service: CRUD, filtering, sorting, and soft-delete operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
# Fields that are safe to sort by
|
||||
_SORTABLE_FIELDS: set[str] = {
|
||||
"make",
|
||||
"model",
|
||||
"fin",
|
||||
"year",
|
||||
"price",
|
||||
"vehicle_type",
|
||||
"availability",
|
||||
"condition",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"power_kw",
|
||||
"mileage_km",
|
||||
}
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: select,
|
||||
vehicle_type: str | None = None,
|
||||
availability: str | None = None,
|
||||
min_price: float | None = None,
|
||||
max_price: float | None = None,
|
||||
search: str | None = None,
|
||||
) -> select:
|
||||
"""Apply WHERE filters to a select statement (always excludes soft-deleted)."""
|
||||
conditions = [Vehicle.deleted_at.is_(None)]
|
||||
|
||||
if vehicle_type:
|
||||
conditions.append(Vehicle.vehicle_type == vehicle_type)
|
||||
if availability:
|
||||
conditions.append(Vehicle.availability == availability)
|
||||
if min_price is not None:
|
||||
conditions.append(Vehicle.price >= min_price)
|
||||
if max_price is not None:
|
||||
conditions.append(Vehicle.price <= max_price)
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
Vehicle.make.ilike(search_pattern),
|
||||
Vehicle.model.ilike(search_pattern),
|
||||
Vehicle.fin.ilike(search_pattern),
|
||||
Vehicle.location.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
return stmt.where(and_(*conditions))
|
||||
|
||||
|
||||
def _apply_sort(stmt: select, sort: str | None = None) -> select:
|
||||
"""Apply ORDER BY to a select statement based on sort param.
|
||||
|
||||
Format: 'field' for ascending, '-field' for descending.
|
||||
"""
|
||||
if not sort:
|
||||
return stmt.order_by(Vehicle.created_at.desc())
|
||||
|
||||
descending = sort.startswith("-")
|
||||
field_name = sort.lstrip("-")
|
||||
|
||||
if field_name not in _SORTABLE_FIELDS:
|
||||
return stmt.order_by(Vehicle.created_at.desc())
|
||||
|
||||
column = getattr(Vehicle, field_name)
|
||||
if descending:
|
||||
return stmt.order_by(column.desc())
|
||||
return stmt.order_by(column.asc())
|
||||
|
||||
|
||||
async def list_vehicles(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
vehicle_type: str | None = None,
|
||||
availability: str | None = None,
|
||||
min_price: float | None = None,
|
||||
max_price: float | None = None,
|
||||
search: str | None = None,
|
||||
sort: str | None = None,
|
||||
) -> tuple[list[Vehicle], int]:
|
||||
"""List vehicles with pagination, filtering, and sorting.
|
||||
|
||||
Returns (vehicles, total_count).
|
||||
"""
|
||||
# Build count query
|
||||
count_stmt = select(func.count(Vehicle.id))
|
||||
count_stmt = _apply_filters(
|
||||
count_stmt,
|
||||
vehicle_type=vehicle_type,
|
||||
availability=availability,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
search=search,
|
||||
)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
# Build data query
|
||||
data_stmt = select(Vehicle)
|
||||
data_stmt = _apply_filters(
|
||||
data_stmt,
|
||||
vehicle_type=vehicle_type,
|
||||
availability=availability,
|
||||
min_price=min_price,
|
||||
max_price=max_price,
|
||||
search=search,
|
||||
)
|
||||
data_stmt = _apply_sort(data_stmt, sort)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(data_stmt)
|
||||
vehicles = list(result.scalars().all())
|
||||
|
||||
return vehicles, total
|
||||
|
||||
|
||||
async def get_vehicle_by_id(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> Vehicle | None:
|
||||
"""Get a single vehicle by ID, excluding soft-deleted."""
|
||||
stmt = select(Vehicle).where(
|
||||
and_(Vehicle.id == vehicle_id, Vehicle.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_vehicle_by_fin(
|
||||
db: AsyncSession, fin: str
|
||||
) -> Vehicle | None:
|
||||
"""Get a single vehicle by FIN, excluding soft-deleted."""
|
||||
stmt = select(Vehicle).where(
|
||||
and_(Vehicle.fin == fin, Vehicle.deleted_at.is_(None))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_vehicle(
|
||||
db: AsyncSession, data: dict[str, Any]
|
||||
) -> Vehicle:
|
||||
"""Create a new vehicle.
|
||||
|
||||
Raises ValueError if FIN already exists.
|
||||
"""
|
||||
existing = await get_vehicle_by_fin(db, data["fin"])
|
||||
if existing is not None:
|
||||
raise ValueError(f"Vehicle with FIN '{data['fin']}' already exists")
|
||||
|
||||
vehicle = Vehicle(**data)
|
||||
db.add(vehicle)
|
||||
await db.flush()
|
||||
await db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
async def update_vehicle(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID, updates: dict[str, Any]
|
||||
) -> Vehicle | None:
|
||||
"""Update a vehicle's fields. Returns None if not found or deleted."""
|
||||
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
return None
|
||||
|
||||
# If FIN is being updated, check for duplicates
|
||||
if "fin" in updates and updates["fin"] != vehicle.fin:
|
||||
existing = await get_vehicle_by_fin(db, updates["fin"])
|
||||
if existing is not None:
|
||||
raise ValueError(f"Vehicle with FIN '{updates['fin']}' already exists")
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(vehicle, key):
|
||||
setattr(vehicle, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
async def soft_delete_vehicle(
|
||||
db: AsyncSession, vehicle_id: uuid.UUID
|
||||
) -> Vehicle | None:
|
||||
"""Soft-delete a vehicle by setting deleted_at. Returns None if not found."""
|
||||
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
return None
|
||||
|
||||
vehicle.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(vehicle)
|
||||
return vehicle
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Field mapping utilities for mobile.de listing format conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
|
||||
# mobile.de category mapping based on vehicle_type
|
||||
_CATEGORY_MAP: dict[str, str] = {
|
||||
"lkw": "Truck",
|
||||
"pkw": "Car",
|
||||
"baumaschine": "ConstructionMachine",
|
||||
"stapler": "ForkliftTruck",
|
||||
"transporter": "Van",
|
||||
}
|
||||
|
||||
# LKW sub-type mapping for mobile.de category refinement
|
||||
_LKW_TYPE_MAP: dict[str, str] = {
|
||||
"sattelzugmaschine": "SemiTractor",
|
||||
"sattelauflieger": "SemiTrailer",
|
||||
"kipper": "Tipper",
|
||||
"kuehlmobil": "RefrigeratedVehicle",
|
||||
"tieflader": "LowLoader",
|
||||
"silofahrzeug": "SiloVehicle",
|
||||
"tankwagen": "TankVehicle",
|
||||
"kranwagen": "CraneVehicle",
|
||||
"muldenkipper": "DumperTruck",
|
||||
"sattelkipper": "SemiTipper",
|
||||
"schwertransporter": "HeavyTransporter",
|
||||
"sonderkonstruktion": "SpecialConstruction",
|
||||
"pritsche": "Flatbed",
|
||||
"planenlkw": "TarpaulinTruck",
|
||||
"boxenlkw": "BoxBodyTruck",
|
||||
"iso_lkw": "IsoTruck",
|
||||
}
|
||||
|
||||
|
||||
def _format_first_registration(reg_date: date | None) -> str | None:
|
||||
"""Convert date to YYYY-MM format for mobile.de."""
|
||||
if reg_date is None:
|
||||
return None
|
||||
return reg_date.strftime("%Y-%m")
|
||||
|
||||
|
||||
def _format_mileage(vehicle: Vehicle) -> dict[str, Any] | None:
|
||||
"""Map mileage or operating hours to mobile.de mileage field."""
|
||||
if vehicle.mileage_km is not None:
|
||||
return {"value": vehicle.mileage_km, "unit": "km"}
|
||||
if vehicle.operating_hours is not None:
|
||||
unit = vehicle.operating_hours_unit or "h"
|
||||
return {"value": float(vehicle.operating_hours), "unit": unit}
|
||||
return None
|
||||
|
||||
|
||||
def _format_price(price: Decimal) -> dict[str, Any]:
|
||||
"""Map price to mobile.de price format."""
|
||||
return {
|
||||
"amount": float(price),
|
||||
"currency": "EUR",
|
||||
}
|
||||
|
||||
|
||||
def _format_power(power_kw: int | None, power_hp: int | None) -> dict[str, Any] | None:
|
||||
"""Map power to mobile.de power format."""
|
||||
if power_kw is None and power_hp is None:
|
||||
return None
|
||||
return {
|
||||
"powerKw": power_kw,
|
||||
"powerHp": power_hp,
|
||||
}
|
||||
|
||||
|
||||
def _format_category(vehicle: Vehicle) -> str:
|
||||
"""Determine mobile.de category from vehicle type and lkw_type."""
|
||||
base = _CATEGORY_MAP.get(vehicle.vehicle_type, "Other")
|
||||
if vehicle.vehicle_type == "lkw" and vehicle.lkw_type:
|
||||
# Try direct match, then strip lkw_ prefix for lookup
|
||||
lkw_key = vehicle.lkw_type
|
||||
if lkw_key in _LKW_TYPE_MAP:
|
||||
return _LKW_TYPE_MAP[lkw_key]
|
||||
stripped = lkw_key.removeprefix("lkw_")
|
||||
if stripped in _LKW_TYPE_MAP:
|
||||
return _LKW_TYPE_MAP[stripped]
|
||||
return base
|
||||
return base
|
||||
|
||||
|
||||
def map_fields(vehicle: Vehicle) -> dict[str, Any]:
|
||||
"""Map a Vehicle model to the mobile.de listing Ad format.
|
||||
|
||||
Converts internal vehicle fields to the mobile.de REST API listing format.
|
||||
See: https://developer.mobile.de/api/seller-listings
|
||||
"""
|
||||
mileage = _format_mileage(vehicle)
|
||||
power = _format_power(vehicle.power_kw, vehicle.power_hp)
|
||||
|
||||
ad: dict[str, Any] = {
|
||||
"vin": vehicle.fin,
|
||||
"make": vehicle.make,
|
||||
"model": vehicle.model,
|
||||
"category": _format_category(vehicle),
|
||||
"price": _format_price(vehicle.price),
|
||||
"availabilityStatus": vehicle.availability,
|
||||
"condition": vehicle.condition,
|
||||
}
|
||||
|
||||
if vehicle.first_registration is not None:
|
||||
ad["firstRegistration"] = _format_first_registration(
|
||||
vehicle.first_registration
|
||||
)
|
||||
|
||||
if mileage is not None:
|
||||
ad["mileage"] = mileage
|
||||
|
||||
if power is not None:
|
||||
ad["power"] = power
|
||||
|
||||
if vehicle.fuel_type is not None:
|
||||
ad["fuelType"] = vehicle.fuel_type
|
||||
|
||||
if vehicle.transmission is not None:
|
||||
ad["transmission"] = vehicle.transmission
|
||||
|
||||
if vehicle.color is not None:
|
||||
ad["color"] = vehicle.color
|
||||
|
||||
if vehicle.year is not None:
|
||||
ad["year"] = vehicle.year
|
||||
|
||||
if vehicle.location is not None:
|
||||
ad["sellerLocation"] = vehicle.location
|
||||
|
||||
if vehicle.machine_type is not None:
|
||||
ad["bodyType"] = vehicle.machine_type
|
||||
elif vehicle.body_type is not None:
|
||||
ad["bodyType"] = vehicle.body_type
|
||||
|
||||
if vehicle.description is not None:
|
||||
ad["description"] = vehicle.description
|
||||
|
||||
return ad
|
||||
Reference in New Issue
Block a user