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!")
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
148
spoolman/database/location.py
Normal file
148
spoolman/database/location.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user