diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 510edff..da63a8b 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -260,7 +260,9 @@ "longitudinal": "Longitudinal", "external_id": "External ID", "spools": "Show Spools", - "not_set": "Not Set" + "not_set": "Not Set", + "spool_count": "Spools", + "total_remaining_weight": "Total Remaining" }, "fields_help": { "name": "Filament name, to distinguish this filament type among others from the same manufacturer. Should contain the color for example.", diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index 0355570..5e60c0b 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -65,6 +65,8 @@ const allColumns: (keyof IFilamentCollapsed & string)[] = [ "article_number", "settings_extruder_temp", "settings_bed_temp", + "spool_count", + "total_remaining_weight", "registered", "comment", ]; @@ -324,6 +326,22 @@ export const FilamentList: React.FC = () => { maxDecimals: 0, 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({ ...commonProps, id: "registered", diff --git a/client/src/pages/filaments/model.tsx b/client/src/pages/filaments/model.tsx index 5371ee8..f045dd8 100644 --- a/client/src/pages/filaments/model.tsx +++ b/client/src/pages/filaments/model.tsx @@ -24,6 +24,8 @@ export interface IFilament { multi_color_direction?: string; external_id?: 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 diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index b0139ec..c565d1f 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -366,7 +366,7 @@ async def find( else: filter_by_ids = None - db_items, total_count = await filament.find( + db_items, total_count, spool_stats = await filament.find( db=db, ids=filter_by_ids, 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 return JSONResponse( 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, ), headers={"x-total-count": str(total_count)}, diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 23f8e1b..49a32dc 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -221,9 +221,25 @@ class Filament(BaseModel): "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 - 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.""" return Filament( id=item.id, @@ -251,6 +267,8 @@ class Filament(BaseModel): ), external_id=item.external_id, extra={field.key: field.value for field in item.extra}, + spool_count=spool_count, + total_remaining_weight=total_remaining_weight, ) diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 13d227e..a63266a 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -114,13 +114,16 @@ async def find( sort_by: Optional[dict[str, SortOrder]] = None, limit: Optional[int] = None, 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. 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. - 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 = ( select(models.Filament) @@ -160,7 +163,25 @@ async def find( if total_count is None: 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(