feat: Add batch operations, dashboard redesign, hierarchical locations
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
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>
This commit is contained in:
164
spoolman/api/v1/location.py
Normal file
164
spoolman/api/v1/location.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""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!")
|
||||
Reference in New Issue
Block a user