diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 331119c..62ddf3a 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -91,7 +91,15 @@ class Spool(BaseModel): first_used: Optional[datetime] = Field(description="First logged occurence of spool usage.") last_used: Optional[datetime] = Field(description="Last logged occurence of spool usage.") filament: Filament = Field(description="The filament type of this spool.") - weight: float = Field(ge=0, description="Remaining weight of filament on the spool.", example=500) + remaining_weight: Optional[float] = Field( + default=None, + ge=0, + description=( + "Estimated remaining weight of filament on the spool. Only set if the filament type has a weight set." + ), + example=500, + ) + used_weight: float = Field(ge=0, description="Consumed weight of filament from the spool.", example=500) location: Optional[str] = Field(max_length=64, description="Where this spool can be found.", example="Shelf A") lot_nr: Optional[str] = Field( max_length=64, @@ -107,13 +115,19 @@ class Spool(BaseModel): @staticmethod def from_db(item: models.Spool) -> "Spool": """Create a new Pydantic spool object from a database spool object.""" + filament = Filament.from_db(item.filament) + remaining_weight: Optional[float] = None + if filament.weight is not None: + remaining_weight = max(filament.weight - item.used_weight, 0) + return Spool( id=item.id, registered=item.registered, first_used=item.first_used, last_used=item.last_used, - filament=Filament.from_db(item.filament), - weight=item.weight, + filament=filament, + used_weight=item.used_weight, + remaining_weight=remaining_weight, location=item.location, lot_nr=item.lot_nr, comment=item.comment, diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 1c60523..db888f9 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -27,14 +27,14 @@ class SpoolParameters(BaseModel): first_used: Optional[datetime] = Field(description="First logged occurence of spool usage.") last_used: Optional[datetime] = Field(description="Last logged occurence of spool usage.") filament_id: int = Field(description="The ID of the filament type of this spool.") - weight: Optional[float] = Field( + remaining_weight: Optional[float] = Field( ge=0, description=( - "Remaining weight of filament on the spool. " - "Leave empty to assume a full spool based on the weight parameters of the filament type." + "Remaining weight of filament on the spool. Can only be used if the filament type has a weight set." ), - example=500, + example=800, ) + used_weight: Optional[float] = Field(ge=0, description="Used weight of filament on the spool.", example=200) location: Optional[str] = Field(max_length=64, description="Where this spool can be found.", example="Shelf A") lot_nr: Optional[str] = Field( max_length=64, @@ -132,7 +132,11 @@ async def get( @router.post( "/", name="Add spool", - description="Add a new spool to the database.", + description=( + "Add a new spool to the database. " + "Only specify either remaining_weight or used_weight. " + "If no weight is set, the spool will be assumed to be full." + ), response_model_exclude_none=True, response_model=Spool, responses={ @@ -143,11 +147,18 @@ async def create( # noqa: ANN201 db: Annotated[AsyncSession, Depends(get_db_session)], body: SpoolParameters, ): + if body.remaining_weight is not None and body.used_weight is not None: + return JSONResponse( + status_code=400, + content={"message": "Only specify either remaining_weight or used_weight."}, + ) + try: db_item = await spool.create( db=db, filament_id=body.filament_id, - weight=body.weight, + remaining_weight=body.remaining_weight, + used_weight=body.used_weight, first_used=body.first_used, last_used=body.last_used, location=body.location, @@ -165,26 +176,46 @@ async def create( # noqa: ANN201 @router.patch( "/{spool_id}", name="Update spool", - description="Update any attribute of a spool. Only fields specified in the request will be affected.", + description=( + "Update any attribute of a spool. " + "Only fields specified in the request will be affected. " + "remaining_weight and used_weight can't be set at the same time." + ), response_model_exclude_none=True, + response_model=Spool, + responses={ + 400: {"model": Message}, + }, ) -async def update( +async def update( # noqa: ANN201 db: Annotated[AsyncSession, Depends(get_db_session)], spool_id: int, body: SpoolUpdateParameters, -) -> Spool: +): patch_data = body.dict(exclude_unset=True) + if body.remaining_weight is not None and body.used_weight is not None: + return JSONResponse( + status_code=400, + content={"message": "Only specify either remaining_weight or used_weight."}, + ) + if "filament_id" in patch_data and body.filament_id is None: raise RequestValidationError( [ErrorWrapper(ValueError("filament_id cannot be unset"), ("query", "filament_id"))], ) - db_item = await spool.update( - db=db, - spool_id=spool_id, - data=patch_data, - ) + try: + db_item = await spool.update( + db=db, + spool_id=spool_id, + data=patch_data, + ) + except ItemCreateError as exc: + return JSONResponse( + status_code=400, + content={"message": str(exc)}, + ) return Spool.from_db(db_item) diff --git a/spoolman/database/models.py b/spoolman/database/models.py index 1090261..6733977 100644 --- a/spoolman/database/models.py +++ b/spoolman/database/models.py @@ -50,7 +50,7 @@ class Spool(Base): last_used: Mapped[Optional[datetime]] = mapped_column() filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id")) filament: Mapped["Filament"] = relationship(back_populates="spools") - weight: Mapped[float] = mapped_column() + used_weight: Mapped[float] = mapped_column() location: Mapped[Optional[str]] = mapped_column(String(64)) lot_nr: Mapped[Optional[str]] = mapped_column(String(64)) comment: Mapped[Optional[str]] = mapped_column(String(1024)) diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 77b28aa..6876461 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -16,7 +16,8 @@ async def create( *, db: AsyncSession, filament_id: int, - weight: Optional[float] = None, + remaining_weight: Optional[float] = None, + used_weight: Optional[float] = None, first_used: Optional[datetime] = None, last_used: Optional[datetime] = None, location: Optional[str] = None, @@ -25,14 +26,17 @@ async def create( ) -> models.Spool: """Add a new spool to the database. Leave weight empty to assume full spool.""" filament_item = await filament.get_by_id(db, filament_id) - if weight is None: - if filament_item.weight is None: - raise ItemCreateError("The weight for neither the spool nor its filament type is set.") - weight = filament_item.weight + if used_weight is None: + if remaining_weight is not None: + if filament_item.weight is None: + raise ItemCreateError("remaining_weight can only be used if the filament type has a weight set.") + used_weight = max(filament_item.weight - remaining_weight, 0) + else: + used_weight = 0 db_item = models.Spool( filament=filament_item, - weight=weight, + used_weight=used_weight, first_used=first_used, last_used=last_used, location=location, @@ -105,6 +109,10 @@ async def update( for k, v in data.items(): if k == "filament_id": spool.filament = await filament.get_by_id(db, v) + elif k == "remaining_weight": + if spool.filament.weight is None: + raise ItemCreateError("remaining_weight can only be used if the filament type has a weight set.") + spool.used_weight = max(spool.filament.weight - v, 0) else: setattr(spool, k, v) await db.flush() @@ -119,33 +127,33 @@ async def delete(db: AsyncSession, spool_id: int) -> None: # TODO: Make unit tests for race conditions on these async def use_weight(db: AsyncSession, spool_id: int, weight: float) -> models.Spool: - """Reduce the weight of a spool. + """Consume filament from a spool by weight. Does nothing if the spool is empty. Args: db (AsyncSession): Database session spool_id (int): Spool ID - weight (float): Weight loss in grams + weight (float): Filament weight to consume, in grams Returns: models.Spool: Updated spool object """ spool = await get_by_id(db, spool_id, with_for_update=True) - spool.weight -= min(spool.weight, weight) + spool.used_weight += weight await db.flush() return spool async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.Spool: - """Reduce the weight of a spool by using a length of filament. + """Consume filament from a spool by length. Does nothing if the spool is empty. Args: db (AsyncSession): Database session spool_id (int): Spool ID - length (float): Length of filament to reduce by + length (float): Length of filament to consume, in mm Returns: models.Spool: Updated spool object @@ -154,11 +162,10 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S filament = spool.filament - weight = weight_from_length( + spool.used_weight += weight_from_length( length=length, radius=filament.diameter / 2, density=filament.density, ) - spool.weight -= min(spool.weight, weight) await db.flush() return spool