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

## 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:
2026-01-21 21:40:01 -06:00
parent 27757bb949
commit 02da984b6e
18 changed files with 1683 additions and 90 deletions

164
spoolman/api/v1/location.py Normal file
View 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!")

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager
from . import auth, backup as backup_module, export, externaldb, field, filament, models, other, print_job, setting, spool, vendor
from . import auth, backup as backup_module, export, externaldb, field, filament, location as location_module, models, other, print_job, setting, spool, vendor
logger = logging.getLogger(__name__)
@@ -116,3 +116,4 @@ app.include_router(export.router)
app.include_router(backup_module.router)
app.include_router(print_job.router)
app.include_router(auth.router)
app.include_router(location_module.router)

View File

@@ -534,6 +534,69 @@ async def delete(
return Message(message="Success!")
# Bulk operations
class BulkArchiveParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to archive/unarchive.")
archived: bool = Field(description="Whether to archive (true) or unarchive (false).")
class BulkDeleteParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to delete.")
class BulkUpdateParameters(BaseModel):
spool_ids: list[int] = Field(description="List of spool IDs to update.")
location: Optional[str] = Field(None, max_length=64, description="New location for all spools.")
@router.post(
"/bulk/archive",
name="Bulk archive spools",
description="Archive or unarchive multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_archive(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkArchiveParameters,
) -> Message:
for spool_id in body.spool_ids:
await spool.update(db=db, spool_id=spool_id, data={"archived": body.archived})
return Message(message=f"Updated {len(body.spool_ids)} spools.")
@router.post(
"/bulk/delete",
name="Bulk delete spools",
description="Delete multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_delete(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkDeleteParameters,
) -> Message:
for spool_id in body.spool_ids:
await spool.delete(db=db, spool_id=spool_id)
return Message(message=f"Deleted {len(body.spool_ids)} spools.")
@router.post(
"/bulk/update",
name="Bulk update spools",
description="Update the same fields on multiple spools at once.",
responses={404: {"model": Message}},
)
async def bulk_update(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: BulkUpdateParameters,
) -> Message:
data = body.model_dump(exclude={"spool_ids"}, exclude_unset=True)
for spool_id in body.spool_ids:
await spool.update(db=db, spool_id=spool_id, data=data)
return Message(message=f"Updated {len(body.spool_ids)} spools.")
@router.put(
"/{spool_id}/use",
name="Use spool filament",

View File

@@ -0,0 +1,148 @@
"""Database operations for Location model."""
from typing import Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from spoolman.database import models
from spoolman.exceptions import ItemNotFoundError
async def create(
*,
db: AsyncSession,
name: str,
parent_id: Optional[int] = None,
location_type: Optional[str] = None,
description: Optional[str] = None,
) -> models.Location:
"""Create a new location."""
# Verify parent exists if specified
if parent_id is not None:
parent = await db.get(models.Location, parent_id)
if parent is None:
raise ItemNotFoundError(f"Parent location with ID {parent_id} not found.")
db_item = models.Location(
name=name,
parent_id=parent_id,
location_type=location_type,
description=description,
)
db.add(db_item)
await db.commit()
return db_item
async def get_by_id(db: AsyncSession, location_id: int) -> models.Location:
"""Get a location by ID."""
stmt = select(models.Location).where(models.Location.id == location_id)
result = await db.execute(stmt)
db_item = result.scalar_one_or_none()
if db_item is None:
raise ItemNotFoundError(f"Location with ID {location_id} not found.")
return db_item
async def find(
*,
db: AsyncSession,
parent_id: Optional[int] = None,
) -> list[models.Location]:
"""Find locations, optionally filtered by parent."""
stmt = select(models.Location).order_by(models.Location.name)
if parent_id is not None:
stmt = stmt.where(models.Location.parent_id == parent_id)
result = await db.execute(stmt)
return list(result.scalars().all())
async def find_all(db: AsyncSession) -> list[models.Location]:
"""Get all locations."""
stmt = select(models.Location).order_by(models.Location.name)
result = await db.execute(stmt)
return list(result.scalars().all())
async def update(
*,
db: AsyncSession,
location_id: int,
data: dict,
) -> models.Location:
"""Update a location."""
db_item = await get_by_id(db, location_id)
# Verify parent exists if changing parent
if "parent_id" in data and data["parent_id"] is not None:
# Prevent setting parent to self
if data["parent_id"] == location_id:
raise ValueError("Location cannot be its own parent.")
parent = await db.get(models.Location, data["parent_id"])
if parent is None:
raise ItemNotFoundError(f"Parent location with ID {data['parent_id']} not found.")
for key, value in data.items():
setattr(db_item, key, value)
await db.commit()
return db_item
async def delete(db: AsyncSession, location_id: int) -> None:
"""Delete a location."""
db_item = await get_by_id(db, location_id)
await db.delete(db_item)
await db.commit()
async def get_full_path(db: AsyncSession, location_id: int) -> str:
"""Get the full path of a location as a string (e.g., 'Room > Shelf > Bin')."""
parts = []
current_id: Optional[int] = location_id
while current_id is not None:
location = await get_by_id(db, current_id)
parts.insert(0, location.name)
current_id = location.parent_id
return " > ".join(parts)
async def get_tree(db: AsyncSession) -> list[dict]:
"""Get all locations as a nested tree structure."""
all_locations = await find_all(db)
# Build lookup dict
by_id = {loc.id: loc for loc in all_locations}
# Build tree
def build_node(loc: models.Location) -> dict:
return {
"id": loc.id,
"name": loc.name,
"location_type": loc.location_type,
"description": loc.description,
"children": [
build_node(by_id[child.id])
for child in all_locations
if child.parent_id == loc.id
],
}
# Get root nodes (no parent)
roots = [loc for loc in all_locations if loc.parent_id is None]
return [build_node(loc) for loc in roots]
async def get_spool_count(db: AsyncSession, location_id: int) -> int:
"""Get the number of spools at this location (not including children)."""
stmt = (
select(func.count(models.Spool.id))
.where(models.Spool.location_id == location_id)
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712
)
result = await db.execute(stmt)
return result.scalar() or 0

View File

@@ -12,6 +12,30 @@ class Base(AsyncAttrs, DeclarativeBase):
pass
class Location(Base):
"""Hierarchical storage location (room, shelf, bin, etc.)."""
__tablename__ = "location"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(64))
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("location.id", ondelete="SET NULL"), index=True)
location_type: Mapped[Optional[str]] = mapped_column(String(32), comment="Type: room, shelf, bin, etc.")
description: Mapped[Optional[str]] = mapped_column(String(256))
# Self-referential relationships
parent: Mapped[Optional["Location"]] = relationship(
"Location",
remote_side="Location.id",
back_populates="children",
)
children: Mapped[list["Location"]] = relationship(
"Location",
back_populates="parent",
)
spools: Mapped[list["Spool"]] = relationship(back_populates="location_obj")
class Vendor(Base):
__tablename__ = "vendor"
@@ -76,7 +100,13 @@ class Spool(Base):
initial_weight: Mapped[Optional[float]] = mapped_column()
spool_weight: Mapped[Optional[float]] = mapped_column()
used_weight: Mapped[float] = mapped_column()
location: Mapped[Optional[str]] = mapped_column(String(64))
location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
location_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("location.id", ondelete="SET NULL"),
index=True,
comment="FK to hierarchical location.",
)
location_obj: Mapped[Optional["Location"]] = relationship(back_populates="spools")
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column()