2026-05-31 20:36:42 +00:00
|
|
|
"""Equipment CRUD endpoints."""
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
|
|
|
from sqlalchemy import select, func, or_
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
|
|
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, Equipment, StockLocation, Contact
|
|
|
|
|
from app.schemas.equipment import (
|
|
|
|
|
EquipmentCreateRequest,
|
|
|
|
|
EquipmentUpdateRequest,
|
|
|
|
|
EquipmentResponse,
|
|
|
|
|
EquipmentListResponse,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/equipment", tags=["equipment"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("", response_model=EquipmentListResponse)
|
|
|
|
|
async def list_equipment(
|
|
|
|
|
page: int = Query(1, ge=1),
|
|
|
|
|
size: int = Query(20, ge=1, le=100),
|
2026-06-10 21:31:41 +00:00
|
|
|
search: str | None = Query(
|
|
|
|
|
None, description="Search in name, brand, serial_number"
|
|
|
|
|
),
|
2026-05-31 20:36:42 +00:00
|
|
|
category: str | None = Query(None, description="Filter by category"),
|
|
|
|
|
status: str | None = Query(None, description="Filter by status"),
|
|
|
|
|
location_id: str | None = Query(None, description="Filter by location"),
|
|
|
|
|
current_user: User = Depends(require_permission("equipment:read")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
"""List equipment with search, filters, pagination."""
|
|
|
|
|
account_id = current_user.account_id
|
|
|
|
|
|
|
|
|
|
base_q = select(Equipment).where(Equipment.account_id == account_id)
|
|
|
|
|
|
|
|
|
|
if search:
|
|
|
|
|
pattern = f"%{search}%"
|
|
|
|
|
base_q = base_q.where(
|
|
|
|
|
or_(
|
|
|
|
|
Equipment.name.ilike(pattern),
|
|
|
|
|
Equipment.brand.ilike(pattern),
|
|
|
|
|
Equipment.serial_number.ilike(pattern),
|
|
|
|
|
Equipment.barcode.ilike(pattern),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if category:
|
|
|
|
|
base_q = base_q.where(Equipment.category == category)
|
|
|
|
|
if status:
|
|
|
|
|
base_q = base_q.where(Equipment.status == status)
|
|
|
|
|
if location_id:
|
|
|
|
|
base_q = base_q.where(Equipment.location_id == location_id)
|
|
|
|
|
|
|
|
|
|
count_q = select(func.count()).select_from(base_q.subquery())
|
|
|
|
|
total = (await session.execute(count_q)).scalar() or 0
|
|
|
|
|
|
|
|
|
|
q = (
|
2026-06-10 21:31:41 +00:00
|
|
|
base_q.options(joinedload(Equipment.location), joinedload(Equipment.supplier))
|
2026-05-31 20:36:42 +00:00
|
|
|
.order_by(Equipment.updated_at.desc())
|
|
|
|
|
.offset((page - 1) * size)
|
|
|
|
|
.limit(size)
|
|
|
|
|
)
|
|
|
|
|
result = await session.execute(q)
|
|
|
|
|
items = result.unique().scalars().all()
|
|
|
|
|
|
|
|
|
|
return EquipmentListResponse(
|
|
|
|
|
items=[EquipmentResponse.model_validate(i) for i in items],
|
|
|
|
|
total=total,
|
|
|
|
|
page=page,
|
|
|
|
|
size=size,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", response_model=EquipmentResponse, status_code=status.HTTP_201_CREATED)
|
|
|
|
|
async def create_equipment(
|
|
|
|
|
body: EquipmentCreateRequest,
|
|
|
|
|
current_user: User = Depends(require_permission("equipment:write")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
"""Create a new equipment item."""
|
|
|
|
|
account_id = current_user.account_id
|
|
|
|
|
|
|
|
|
|
# Validate location if provided
|
|
|
|
|
if body.location_id:
|
|
|
|
|
loc_result = await session.execute(
|
|
|
|
|
select(StockLocation).where(
|
|
|
|
|
StockLocation.id == body.location_id,
|
|
|
|
|
StockLocation.account_id == account_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if not loc_result.scalars().first():
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Stock location not found",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Validate supplier if provided
|
|
|
|
|
if body.supplier_id:
|
|
|
|
|
sup_result = await session.execute(
|
|
|
|
|
select(Contact).where(
|
|
|
|
|
Contact.id == body.supplier_id,
|
|
|
|
|
Contact.account_id == account_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if not sup_result.scalars().first():
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Supplier (contact) not found",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
item = Equipment(account_id=account_id, **body.model_dump())
|
|
|
|
|
session.add(item)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(item)
|
|
|
|
|
|
|
|
|
|
await session.execute(
|
|
|
|
|
select(Equipment)
|
|
|
|
|
.options(joinedload(Equipment.location), joinedload(Equipment.supplier))
|
|
|
|
|
.where(Equipment.id == item.id)
|
|
|
|
|
)
|
|
|
|
|
await session.refresh(item)
|
|
|
|
|
|
|
|
|
|
return EquipmentResponse.model_validate(item)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{item_id}", response_model=EquipmentResponse)
|
|
|
|
|
async def get_equipment(
|
|
|
|
|
item_id: str,
|
|
|
|
|
current_user: User = Depends(require_permission("equipment:read")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
"""Get a specific equipment item."""
|
|
|
|
|
account_id = current_user.account_id
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
select(Equipment)
|
|
|
|
|
.options(joinedload(Equipment.location), joinedload(Equipment.supplier))
|
|
|
|
|
.where(Equipment.id == item_id, Equipment.account_id == account_id)
|
|
|
|
|
)
|
|
|
|
|
item = result.unique().scalars().first()
|
|
|
|
|
if not item:
|
2026-06-10 21:31:41 +00:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found"
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
return EquipmentResponse.model_validate(item)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/{item_id}", response_model=EquipmentResponse)
|
|
|
|
|
async def update_equipment(
|
|
|
|
|
item_id: str,
|
|
|
|
|
body: EquipmentUpdateRequest,
|
|
|
|
|
current_user: User = Depends(require_permission("equipment:write")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
"""Update an existing equipment item."""
|
|
|
|
|
account_id = current_user.account_id
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
select(Equipment)
|
|
|
|
|
.options(joinedload(Equipment.location), joinedload(Equipment.supplier))
|
|
|
|
|
.where(Equipment.id == item_id, Equipment.account_id == account_id)
|
|
|
|
|
)
|
|
|
|
|
item = result.unique().scalars().first()
|
|
|
|
|
if not item:
|
2026-06-10 21:31:41 +00:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Equipment 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(item, field, value)
|
|
|
|
|
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(item)
|
|
|
|
|
return EquipmentResponse.model_validate(item)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
async def delete_equipment(
|
|
|
|
|
item_id: str,
|
|
|
|
|
current_user: User = Depends(require_permission("equipment:delete")),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
):
|
|
|
|
|
"""Delete an equipment item."""
|
|
|
|
|
account_id = current_user.account_id
|
|
|
|
|
result = await session.execute(
|
2026-06-10 21:31:41 +00:00
|
|
|
select(Equipment).where(
|
|
|
|
|
Equipment.id == item_id, Equipment.account_id == account_id
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
)
|
|
|
|
|
item = result.scalars().first()
|
|
|
|
|
if not item:
|
2026-06-10 21:31:41 +00:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found"
|
|
|
|
|
)
|
2026-05-31 20:36:42 +00:00
|
|
|
|
|
|
|
|
await session.delete(item)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return None
|