Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
## Batch Operations (#13) - Add POST /spool/bulk/archive, /bulk/delete, /bulk/update endpoints - Add row selection with checkboxes to spool list - Add floating BatchActionBar with archive/unarchive/delete/move actions - Add confirmation modals for all batch operations ## Dashboard Redesign (#12) - Install recharts for pie charts - Add AlertCards (low stock, pending jobs, needs weighing) - Add MaterialDistributionChart (pie chart by material) - Add QuickStats row (total spools, filaments, vendors, weight) - Refactor home page layout with new components ## Hierarchical Locations (#9) - Add Location model with self-referential parent relationship - Add migration for location table and spool.location_id FK - Add location database operations (CRUD, tree, full path) - Add location API endpoints (list, tree, CRUD) - Add LocationTreeSelect component with nested tree display - Add LocationCreateModal for inline location creation - Keep legacy location string field for backward compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
165 lines
5.1 KiB
Python
165 lines
5.1 KiB
Python
"""Location related endpoints."""
|
|
|
|
from typing import Annotated, Optional
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from spoolman.api.v1.models import Message
|
|
from spoolman.database import location
|
|
from spoolman.database.database import get_db_session
|
|
from spoolman.database import models
|
|
|
|
router = APIRouter(
|
|
prefix="/location",
|
|
tags=["location"],
|
|
)
|
|
|
|
|
|
class LocationResponse(BaseModel):
|
|
"""Location response model."""
|
|
|
|
id: int
|
|
name: str
|
|
parent_id: Optional[int] = None
|
|
location_type: Optional[str] = None
|
|
description: Optional[str] = None
|
|
full_path: Optional[str] = None # Only populated for single-item requests
|
|
spool_count: Optional[int] = None # Only populated when requested
|
|
|
|
@staticmethod
|
|
def from_db(item: models.Location, full_path: Optional[str] = None) -> "LocationResponse":
|
|
return LocationResponse(
|
|
id=item.id,
|
|
name=item.name,
|
|
parent_id=item.parent_id,
|
|
location_type=item.location_type,
|
|
description=item.description,
|
|
full_path=full_path,
|
|
)
|
|
|
|
|
|
class LocationTreeNode(BaseModel):
|
|
"""Location tree node for nested structure."""
|
|
|
|
id: int
|
|
name: str
|
|
location_type: Optional[str] = None
|
|
description: Optional[str] = None
|
|
children: list["LocationTreeNode"] = []
|
|
|
|
|
|
class LocationParameters(BaseModel):
|
|
"""Parameters for creating a location."""
|
|
|
|
name: str = Field(max_length=64, description="Location name.")
|
|
parent_id: Optional[int] = Field(None, description="Parent location ID.")
|
|
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
|
|
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
|
|
|
|
|
|
class LocationUpdateParameters(BaseModel):
|
|
"""Parameters for updating a location."""
|
|
|
|
name: Optional[str] = Field(None, max_length=64, description="Location name.")
|
|
parent_id: Optional[int] = Field(None, description="Parent location ID.")
|
|
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
|
|
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
name="Find locations",
|
|
description="Get all locations as a flat list.",
|
|
response_model=list[LocationResponse],
|
|
)
|
|
async def find_locations(
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
) -> list[LocationResponse]:
|
|
db_items = await location.find_all(db=db)
|
|
return [LocationResponse.from_db(item) for item in db_items]
|
|
|
|
|
|
@router.get(
|
|
"/tree",
|
|
name="Get location tree",
|
|
description="Get all locations as a nested tree structure.",
|
|
response_model=list[LocationTreeNode],
|
|
)
|
|
async def get_location_tree(
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
) -> list[dict]:
|
|
return await location.get_tree(db=db)
|
|
|
|
|
|
@router.get(
|
|
"/{location_id}",
|
|
name="Get location",
|
|
description="Get a specific location by ID.",
|
|
response_model=LocationResponse,
|
|
responses={404: {"model": Message}},
|
|
)
|
|
async def get_location(
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
location_id: int,
|
|
) -> LocationResponse:
|
|
db_item = await location.get_by_id(db=db, location_id=location_id)
|
|
full_path = await location.get_full_path(db=db, location_id=location_id)
|
|
spool_count = await location.get_spool_count(db=db, location_id=location_id)
|
|
response = LocationResponse.from_db(db_item, full_path=full_path)
|
|
response.spool_count = spool_count
|
|
return response
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
name="Create location",
|
|
description="Create a new location.",
|
|
response_model=LocationResponse,
|
|
responses={400: {"model": Message}, 404: {"model": Message}},
|
|
)
|
|
async def create_location(
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
body: LocationParameters,
|
|
) -> LocationResponse:
|
|
db_item = await location.create(
|
|
db=db,
|
|
name=body.name,
|
|
parent_id=body.parent_id,
|
|
location_type=body.location_type,
|
|
description=body.description,
|
|
)
|
|
return LocationResponse.from_db(db_item)
|
|
|
|
|
|
@router.patch(
|
|
"/{location_id}",
|
|
name="Update location",
|
|
description="Update a location.",
|
|
response_model=LocationResponse,
|
|
responses={400: {"model": Message}, 404: {"model": Message}},
|
|
)
|
|
async def update_location(
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
location_id: int,
|
|
body: LocationUpdateParameters,
|
|
) -> LocationResponse:
|
|
data = body.model_dump(exclude_unset=True)
|
|
db_item = await location.update(db=db, location_id=location_id, data=data)
|
|
return LocationResponse.from_db(db_item)
|
|
|
|
|
|
@router.delete(
|
|
"/{location_id}",
|
|
name="Delete location",
|
|
description="Delete a location. Spools using this location will have their location_id set to null.",
|
|
responses={404: {"model": Message}},
|
|
)
|
|
async def delete_location(
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
location_id: int,
|
|
) -> Message:
|
|
await location.delete(db=db, location_id=location_id)
|
|
return Message(message="Success!")
|