209 lines
5.7 KiB
Python
209 lines
5.7 KiB
Python
|
|
"""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 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
|