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>
149 lines
4.5 KiB
Python
149 lines
4.5 KiB
Python
"""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
|