Initial commit: Rentman Clone - Phase 0-6 (T001-T023)

Completed:
- Phase 0: Project Setup (T001-T003) - Docker Compose, FastAPI skeleton, React SPA
- Phase 1: Auth System (T004-T008) - DB models, JWT auth, RBAC middleware, user management
- Phase 2: Contacts & Tags (T009-T011) - CRUD API + UI
- Phase 3: Equipment Catalog (T012-T014) - Models, API, UI with barcode/QR
- Phase 4: Crew Management (T015-T017) - Models, availability, UI
- Phase 5: Vehicle Fleet (T018-T020) - Models, assignments, UI
- Phase 6: Projects (T021-T023) - Project hierarchy models, CRUD API, list/detail UI
This commit is contained in:
Agent Zero
2026-05-31 20:36:42 +00:00
commit 7f7da15965
135 changed files with 18980 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
"""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
from app.api.deps import get_current_user, require_permission
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 =====
@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(
or_(Vehicle.name.ilike(pattern), Vehicle.license_plate.ilike(pattern),
Vehicle.brand.ilike(pattern), Vehicle.model.ilike(pattern))
)
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
q = base_q.options(selectinload(Vehicle.assignments)).order_by(Vehicle.name).offset((page-1)*size).limit(size)
result = await session.execute(q)
items = result.unique().scalars().all()
return VehicleListResponse(items=[VehicleResponse.model_validate(i) for i in items], total=total, page=page, size=size)
@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(
select(Vehicle).options(selectinload(Vehicle.assignments))
.where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id)
)
vehicle = result.unique().scalars().first()
if not vehicle:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found")
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(
select(Vehicle).options(selectinload(Vehicle.assignments))
.where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id)
)
vehicle = result.unique().scalars().first()
if not vehicle:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found")
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
result = await session.execute(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id))
vehicle = result.scalars().first()
if not vehicle:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found")
await session.delete(vehicle)
await session.commit()
return None
# ===== VehicleAssignment Endpoints =====
@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
base_q = select(VehicleAssignment).join(Vehicle).where(Vehicle.account_id == account_id)
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
q = base_q.order_by(VehicleAssignment.start_date.desc()).offset((page-1)*size).limit(size)
result = await session.execute(q)
items = result.scalars().all()
return VehicleAssignmentListResponse(items=[VehicleAssignmentResponse.model_validate(i) for i in items], total=total, page=page, size=size)
@assign_router.post("", response_model=VehicleAssignmentResponse, status_code=status.HTTP_201_CREATED)
async def create_assignment(
body: VehicleAssignmentCreateRequest,
current_user: User = Depends(require_permission("vehicles:write")),
session: AsyncSession = Depends(get_async_session),
):
v_result = await session.execute(select(Vehicle).where(Vehicle.id == body.vehicle_id, Vehicle.account_id == current_user.account_id))
if not v_result.scalars().first():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found")
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(
select(VehicleAssignment).join(Vehicle).where(VehicleAssignment.id == assign_id, Vehicle.account_id == current_user.account_id)
)
assign = result.scalars().first()
if not assign:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
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(
select(VehicleAssignment).join(Vehicle).where(VehicleAssignment.id == assign_id, Vehicle.account_id == current_user.account_id)
)
assign = result.scalars().first()
if not assign:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found")
await session.delete(assign)
await session.commit()
return None