2026-05-31 20:36:42 +00:00
|
|
|
"""Vehicle CRUD endpoints including assignments."""
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
|
|
|
from sqlalchemy import select, func, or_
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
|
|
2026-06-10 21:31:41 +00:00
|
|
|
from app.api.deps import require_permission
|
2026-05-31 20:36:42 +00:00
|
|
|
from app.db.session import get_async_session
|
|
|
|
|
from app.models import User, Vehicle, VehicleAssignment
|
|
|
|
|
from app.schemas.vehicle import (
|
|
|
|
|
VehicleCreateRequest,
|
|
|
|
|
VehicleUpdateRequest,
|
|
|
|
|
VehicleResponse,
|
|
|
|
|
VehicleListResponse,
|
|
|
|
|
VehicleAssignmentCreateRequest,
|
|
|
|
|
VehicleAssignmentUpdateRequest,
|
|
|
|
|
VehicleAssignmentResponse,
|
|
|
|
|
VehicleAssignmentListResponse,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/vehicles", tags=["vehicles"])
|
|
|
|
|
assign_router = APIRouter(prefix="/vehicle-assignments", tags=["vehicle-assignments"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===== Vehicle Endpoints =====
|
|
|
|
|
|
2026-06-10 21:31:41 +00:00
|
|
|
|
2026-05-31 20:36:42 +00:00
|
|
|
@router.get("", response_model=VehicleListResponse)
|
|
|
|
|
async def list_vehicles(
|
|
|
|
|
page: int = Query(1, ge=1),
|
|
|
|
|
size: int = Query(20, ge=1, le=100),
|
|
|
|
|
search: str | None = Query(None, description="Search in name, plate, brand"),
|
|
|
|
|
vehicle_type: str | None = Query(None),
|
|
|
|
|
is_active: bool | None = Query(None),
|
|
|
|
|
current_user: User = Depends(require_permission("vehicles:read")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
account_id = current_user.account_id
|
|
|
|
|
base_q = select(Vehicle).where(Vehicle.account_id == account_id)
|
|
|
|
|
|
|
|
|
|
if search:
|
|
|
|
|
pattern = f"%{search}%"
|
|
|
|
|
base_q = base_q.where(
|
2026-06-10 21:31:41 +00:00
|
|
|
or_(
|
|
|
|
|
Vehicle.name.ilike(pattern),
|
|
|
|
|
Vehicle.license_plate.ilike(pattern),
|
|
|
|
|
Vehicle.brand.ilike(pattern),
|
|
|
|
|
Vehicle.model.ilike(pattern),
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
)
|
|
|
|
|
if vehicle_type:
|
|
|
|
|
base_q = base_q.where(Vehicle.vehicle_type == vehicle_type)
|
|
|
|
|
if is_active is not None:
|
|
|
|
|
base_q = base_q.where(Vehicle.is_active == is_active)
|
|
|
|
|
|
|
|
|
|
count_q = select(func.count()).select_from(base_q.subquery())
|
|
|
|
|
total = (await session.execute(count_q)).scalar() or 0
|
|
|
|
|
|
2026-06-10 21:31:41 +00:00
|
|
|
q = (
|
|
|
|
|
base_q.options(selectinload(Vehicle.assignments))
|
|
|
|
|
.order_by(Vehicle.name)
|
|
|
|
|
.offset((page - 1) * size)
|
|
|
|
|
.limit(size)
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
result = await session.execute(q)
|
|
|
|
|
items = result.unique().scalars().all()
|
|
|
|
|
|
2026-06-10 21:31:41 +00:00
|
|
|
return VehicleListResponse(
|
|
|
|
|
items=[VehicleResponse.model_validate(i) for i in items],
|
|
|
|
|
total=total,
|
|
|
|
|
page=page,
|
|
|
|
|
size=size,
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", response_model=VehicleResponse, status_code=status.HTTP_201_CREATED)
|
|
|
|
|
async def create_vehicle(
|
|
|
|
|
body: VehicleCreateRequest,
|
|
|
|
|
current_user: User = Depends(require_permission("vehicles:write")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
account_id = current_user.account_id
|
|
|
|
|
vehicle = Vehicle(account_id=account_id, **body.model_dump())
|
|
|
|
|
session.add(vehicle)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(vehicle)
|
|
|
|
|
return VehicleResponse.model_validate(vehicle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{vehicle_id}", response_model=VehicleResponse)
|
|
|
|
|
async def get_vehicle(
|
|
|
|
|
vehicle_id: str,
|
|
|
|
|
current_user: User = Depends(require_permission("vehicles:read")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
account_id = current_user.account_id
|
|
|
|
|
result = await session.execute(
|
2026-06-10 21:31:41 +00:00
|
|
|
select(Vehicle)
|
|
|
|
|
.options(selectinload(Vehicle.assignments))
|
2026-05-31 20:36:42 +00:00
|
|
|
.where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id)
|
|
|
|
|
)
|
|
|
|
|
vehicle = result.unique().scalars().first()
|
|
|
|
|
if not vehicle:
|
2026-06-10 21:31:41 +00:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found"
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
return VehicleResponse.model_validate(vehicle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/{vehicle_id}", response_model=VehicleResponse)
|
|
|
|
|
async def update_vehicle(
|
|
|
|
|
vehicle_id: str,
|
|
|
|
|
body: VehicleUpdateRequest,
|
|
|
|
|
current_user: User = Depends(require_permission("vehicles:write")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
account_id = current_user.account_id
|
|
|
|
|
result = await session.execute(
|
2026-06-10 21:31:41 +00:00
|
|
|
select(Vehicle)
|
|
|
|
|
.options(selectinload(Vehicle.assignments))
|
2026-05-31 20:36:42 +00:00
|
|
|
.where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id)
|
|
|
|
|
)
|
|
|
|
|
vehicle = result.unique().scalars().first()
|
|
|
|
|
if not vehicle:
|
2026-06-10 21:31:41 +00:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found"
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
update_data = body.model_dump(exclude_unset=True)
|
|
|
|
|
for field, value in update_data.items():
|
|
|
|
|
setattr(vehicle, field, value)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(vehicle)
|
|
|
|
|
return VehicleResponse.model_validate(vehicle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{vehicle_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
async def delete_vehicle(
|
|
|
|
|
vehicle_id: str,
|
|
|
|
|
current_user: User = Depends(require_permission("vehicles:delete")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
account_id = current_user.account_id
|
2026-06-10 21:31:41 +00:00
|
|
|
result = await session.execute(
|
|
|
|
|
select(Vehicle).where(
|
|
|
|
|
Vehicle.id == vehicle_id, Vehicle.account_id == account_id
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
vehicle = result.scalars().first()
|
|
|
|
|
if not vehicle:
|
2026-06-10 21:31:41 +00:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found"
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
await session.delete(vehicle)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===== VehicleAssignment Endpoints =====
|
|
|
|
|
|
2026-06-10 21:31:41 +00:00
|
|
|
|
2026-05-31 20:36:42 +00:00
|
|
|
@assign_router.get("", response_model=VehicleAssignmentListResponse)
|
|
|
|
|
async def list_assignments(
|
|
|
|
|
page: int = Query(1, ge=1),
|
|
|
|
|
size: int = Query(50, ge=1, le=200),
|
|
|
|
|
vehicle_id: str | None = Query(None),
|
|
|
|
|
status: str | None = Query(None),
|
|
|
|
|
current_user: User = Depends(require_permission("vehicles:read")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
account_id = current_user.account_id
|
2026-06-10 21:31:41 +00:00
|
|
|
base_q = (
|
|
|
|
|
select(VehicleAssignment).join(Vehicle).where(Vehicle.account_id == account_id)
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
if vehicle_id:
|
|
|
|
|
base_q = base_q.where(VehicleAssignment.vehicle_id == vehicle_id)
|
|
|
|
|
if status:
|
|
|
|
|
base_q = base_q.where(VehicleAssignment.status == status)
|
|
|
|
|
count_q = select(func.count()).select_from(base_q.subquery())
|
|
|
|
|
total = (await session.execute(count_q)).scalar() or 0
|
2026-06-10 21:31:41 +00:00
|
|
|
q = (
|
|
|
|
|
base_q.order_by(VehicleAssignment.start_date.desc())
|
|
|
|
|
.offset((page - 1) * size)
|
|
|
|
|
.limit(size)
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
result = await session.execute(q)
|
|
|
|
|
items = result.scalars().all()
|
2026-06-10 21:31:41 +00:00
|
|
|
return VehicleAssignmentListResponse(
|
|
|
|
|
items=[VehicleAssignmentResponse.model_validate(i) for i in items],
|
|
|
|
|
total=total,
|
|
|
|
|
page=page,
|
|
|
|
|
size=size,
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
|
|
|
|
|
|
2026-06-10 21:31:41 +00:00
|
|
|
@assign_router.post(
|
|
|
|
|
"", response_model=VehicleAssignmentResponse, status_code=status.HTTP_201_CREATED
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
async def create_assignment(
|
|
|
|
|
body: VehicleAssignmentCreateRequest,
|
|
|
|
|
current_user: User = Depends(require_permission("vehicles:write")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
2026-06-10 21:31:41 +00:00
|
|
|
v_result = await session.execute(
|
|
|
|
|
select(Vehicle).where(
|
|
|
|
|
Vehicle.id == body.vehicle_id, Vehicle.account_id == current_user.account_id
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
if not v_result.scalars().first():
|
2026-06-10 21:31:41 +00:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found"
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
assign = VehicleAssignment(**body.model_dump())
|
|
|
|
|
session.add(assign)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(assign)
|
|
|
|
|
return VehicleAssignmentResponse.model_validate(assign)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@assign_router.put("/{assign_id}", response_model=VehicleAssignmentResponse)
|
|
|
|
|
async def update_assignment(
|
|
|
|
|
assign_id: str,
|
|
|
|
|
body: VehicleAssignmentUpdateRequest,
|
|
|
|
|
current_user: User = Depends(require_permission("vehicles:write")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
result = await session.execute(
|
2026-06-10 21:31:41 +00:00
|
|
|
select(VehicleAssignment)
|
|
|
|
|
.join(Vehicle)
|
|
|
|
|
.where(
|
|
|
|
|
VehicleAssignment.id == assign_id,
|
|
|
|
|
Vehicle.account_id == current_user.account_id,
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
)
|
|
|
|
|
assign = result.scalars().first()
|
|
|
|
|
if not assign:
|
2026-06-10 21:31:41 +00:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found"
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
update_data = body.model_dump(exclude_unset=True)
|
|
|
|
|
for field, value in update_data.items():
|
|
|
|
|
setattr(assign, field, value)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(assign)
|
|
|
|
|
return VehicleAssignmentResponse.model_validate(assign)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@assign_router.delete("/{assign_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
async def delete_assignment(
|
|
|
|
|
assign_id: str,
|
|
|
|
|
current_user: User = Depends(require_permission("vehicles:delete")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
result = await session.execute(
|
2026-06-10 21:31:41 +00:00
|
|
|
select(VehicleAssignment)
|
|
|
|
|
.join(Vehicle)
|
|
|
|
|
.where(
|
|
|
|
|
VehicleAssignment.id == assign_id,
|
|
|
|
|
Vehicle.account_id == current_user.account_id,
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
)
|
|
|
|
|
assign = result.scalars().first()
|
|
|
|
|
if not assign:
|
2026-06-10 21:31:41 +00:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found"
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
await session.delete(assign)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return None
|