feat(filament): add spool count and total remaining weight columns

Adds computed spool statistics to filament list:
- spool_count: Number of non-archived spools of this filament
- total_remaining_weight: Sum of remaining weight across all spools

Backend changes:
- Modified database/filament.py find() to compute stats via subquery
- Added fields to Filament pydantic model in api/v1/models.py
- Updated filament API endpoint to include stats in response

Frontend changes:
- Added fields to IFilament interface
- Added columns to filament list table
- Added translation keys

Closes #15

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 23:31:47 -06:00
parent 0a741c9712
commit 48bd516c0f
6 changed files with 75 additions and 7 deletions

View File

@@ -260,7 +260,9 @@
"longitudinal": "Longitudinal", "longitudinal": "Longitudinal",
"external_id": "External ID", "external_id": "External ID",
"spools": "Show Spools", "spools": "Show Spools",
"not_set": "Not Set" "not_set": "Not Set",
"spool_count": "Spools",
"total_remaining_weight": "Total Remaining"
}, },
"fields_help": { "fields_help": {
"name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.", "name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.",

View File

@@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [
"article_number", "article_number",
"settings_extruder_temp", "settings_extruder_temp",
"settings_bed_temp", "settings_bed_temp",
"spool_count",
"total_remaining_weight",
"registered", "registered",
"comment", "comment",
]; ];
@@ -324,6 +326,22 @@ export const FilamentList: React.FC<IResourceComponentsProps> = () => {
maxDecimals: 0, maxDecimals: 0,
width: 100, width: 100,
}), }),
NumberColumn({
...commonProps,
id: "spool_count",
i18ncat: "filament",
unit: "",
maxDecimals: 0,
width: 80,
}),
NumberColumn({
...commonProps,
id: "total_remaining_weight",
i18ncat: "filament",
unit: "g",
maxDecimals: 0,
width: 120,
}),
DateColumn({ DateColumn({
...commonProps, ...commonProps,
id: "registered", id: "registered",

View File

@@ -24,6 +24,8 @@ export interface IFilament {
multi_color_direction?: string; multi_color_direction?: string;
external_id?: string; external_id?: string;
extra: { [key: string]: string }; extra: { [key: string]: string };
spool_count?: number;
total_remaining_weight?: number;
} }
// IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types // IFilamentParsedExtras is the same as IFilament, but with the extra field parsed into its real types

View File

@@ -366,7 +366,7 @@ async def find(
else: else:
filter_by_ids = None filter_by_ids = None
db_items, total_count = await filament.find( db_items, total_count, spool_stats = await filament.find(
db=db, db=db,
ids=filter_by_ids, ids=filter_by_ids,
vendor_name=vendor_name if vendor_name is not None else vendor_name_old, vendor_name=vendor_name if vendor_name is not None else vendor_name_old,
@@ -383,7 +383,14 @@ async def find(
# Set x-total-count header for pagination # Set x-total-count header for pagination
return JSONResponse( return JSONResponse(
content=jsonable_encoder( content=jsonable_encoder(
(Filament.from_db(db_item) for db_item in db_items), (
Filament.from_db(
db_item,
spool_count=spool_stats.get(db_item.id, (0, 0))[0],
total_remaining_weight=spool_stats.get(db_item.id, (0, 0))[1],
)
for db_item in db_items
),
exclude_none=True, exclude_none=True,
), ),
headers={"x-total-count": str(total_count)}, headers={"x-total-count": str(total_count)},

View File

@@ -221,9 +221,25 @@ class Filament(BaseModel):
"Query the /fields endpoint for more details about the fields." "Query the /fields endpoint for more details about the fields."
), ),
) )
spool_count: Optional[int] = Field(
None,
ge=0,
description="Number of spools of this filament (excluding archived). Computed field.",
examples=[5],
)
total_remaining_weight: Optional[float] = Field(
None,
ge=0,
description="Total remaining weight across all spools of this filament (excluding archived), in grams. Computed field.",
examples=[2500.0],
)
@staticmethod @staticmethod
def from_db(item: models.Filament) -> "Filament": def from_db(
item: models.Filament,
spool_count: Optional[int] = None,
total_remaining_weight: Optional[float] = None,
) -> "Filament":
"""Create a new Pydantic filament object from a database filament object.""" """Create a new Pydantic filament object from a database filament object."""
return Filament( return Filament(
id=item.id, id=item.id,
@@ -251,6 +267,8 @@ class Filament(BaseModel):
), ),
external_id=item.external_id, external_id=item.external_id,
extra={field.key: field.value for field in item.extra}, extra={field.key: field.value for field in item.extra},
spool_count=spool_count,
total_remaining_weight=total_remaining_weight,
) )

View File

@@ -114,13 +114,16 @@ async def find(
sort_by: Optional[dict[str, SortOrder]] = None, sort_by: Optional[dict[str, SortOrder]] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
offset: int = 0, offset: int = 0,
) -> tuple[list[models.Filament], int]: ) -> tuple[list[models.Filament], int, dict[int, tuple[int, float]]]:
"""Find a list of filament objects by search criteria. """Find a list of filament objects by search criteria.
Sort by a field by passing a dict with the field name as key and the sort order as value. Sort by a field by passing a dict with the field name as key and the sort order as value.
The field name can contain nested fields, e.g. vendor.name. The field name can contain nested fields, e.g. vendor.name.
Returns a tuple containing the list of items and the total count of matching items. Returns a tuple containing:
- list of filaments
- total count of matching items
- dict mapping filament_id to (spool_count, total_remaining_weight)
""" """
stmt = ( stmt = (
select(models.Filament) select(models.Filament)
@@ -160,7 +163,25 @@ async def find(
if total_count is None: if total_count is None:
total_count = len(result) total_count = len(result)
return result, total_count # Fetch spool statistics for all filaments in the result
spool_stats: dict[int, tuple[int, float]] = {}
if result:
filament_ids = [f.id for f in result]
stats_stmt = (
select(
models.Spool.filament_id,
func.count(models.Spool.id).label("spool_count"),
func.coalesce(func.sum(models.Spool.initial_weight - models.Spool.used_weight), 0).label("total_remaining"),
)
.where(models.Spool.filament_id.in_(filament_ids))
.where((models.Spool.archived == False) | (models.Spool.archived == None)) # noqa: E712, E711
.group_by(models.Spool.filament_id)
)
stats_rows = await db.execute(stats_stmt)
for row in stats_rows.all():
spool_stats[row.filament_id] = (row.spool_count, float(row.total_remaining or 0))
return result, total_count, spool_stats
async def update( async def update(