Sync Bambuddy thumbnails to InvenTree parts
This commit is contained in:
@@ -20,6 +20,8 @@ WEBHOOK_SHARED_SECRET=
|
|||||||
|
|
||||||
# Sync behavior.
|
# Sync behavior.
|
||||||
SYNC_SUCCESS_ONLY=true
|
SYNC_SUCCESS_ONLY=true
|
||||||
|
SYNC_PART_IMAGES=true
|
||||||
|
OVERWRITE_PART_IMAGES=false
|
||||||
DEFAULT_STOCK_QUANTITY=1
|
DEFAULT_STOCK_QUANTITY=1
|
||||||
INVENTREE_STOCK_STATUS=10
|
INVENTREE_STOCK_STATUS=10
|
||||||
PART_IPN_PREFIX=BMB
|
PART_IPN_PREFIX=BMB
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -105,6 +105,18 @@ For each synced `Part`, the service updates these InvenTree parameters when matc
|
|||||||
- `Weight`: filament weight per printed item in grams. If a Bambuddy archive has `quantity=2`, the total filament weight is divided by 2.
|
- `Weight`: filament weight per printed item in grams. If a Bambuddy archive has `quantity=2`, the total filament weight is divided by 2.
|
||||||
- `PrintTime`: print duration from Bambuddy. The visible value is formatted as `1h 6m 1s`; `data_numeric` stores the duration in seconds.
|
- `PrintTime`: print duration from Bambuddy. The visible value is formatted as `1h 6m 1s`; `data_numeric` stores the duration in seconds.
|
||||||
|
|
||||||
|
## InvenTree Part Images
|
||||||
|
|
||||||
|
When `SYNC_PART_IMAGES=true`, the service downloads the Bambuddy archive thumbnail and uploads it to the InvenTree `Part.image` field.
|
||||||
|
|
||||||
|
By default, existing InvenTree part images are preserved:
|
||||||
|
|
||||||
|
```env
|
||||||
|
OVERWRITE_PART_IMAGES=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `OVERWRITE_PART_IMAGES=true` if Bambuddy thumbnails should replace existing part images.
|
||||||
|
|
||||||
## Useful Endpoints
|
## Useful Endpoints
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ from .http_errors import ExternalApiError
|
|||||||
from .models import Archive
|
from .models import Archive
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadedFile:
|
||||||
|
def __init__(self, *, content: bytes, content_type: str, filename: str) -> None:
|
||||||
|
self.content = content
|
||||||
|
self.content_type = content_type
|
||||||
|
self.filename = filename
|
||||||
|
|
||||||
|
|
||||||
class BambuddyClient:
|
class BambuddyClient:
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings) -> None:
|
||||||
base_url = settings.bambuddy_base_url.rstrip("/")
|
base_url = settings.bambuddy_base_url.rstrip("/")
|
||||||
@@ -31,6 +38,22 @@ class BambuddyClient:
|
|||||||
async def get_system_info(self) -> dict[str, Any]:
|
async def get_system_info(self) -> dict[str, Any]:
|
||||||
return await self._request("GET", "/system/info")
|
return await self._request("GET", "/system/info")
|
||||||
|
|
||||||
|
async def get_archive_thumbnail(self, archive_id: int) -> DownloadedFile | None:
|
||||||
|
response = await self.client.get(f"/archives/{archive_id}/thumbnail")
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
if response.status_code >= 400:
|
||||||
|
body = response.text[:1000]
|
||||||
|
raise ExternalApiError(f"Bambuddy GET /archives/{archive_id}/thumbnail failed: HTTP {response.status_code}: {body}")
|
||||||
|
|
||||||
|
content_type = response.headers.get("content-type", "image/png").split(";")[0]
|
||||||
|
extension = "jpg" if content_type == "image/jpeg" else "png"
|
||||||
|
return DownloadedFile(
|
||||||
|
content=response.content,
|
||||||
|
content_type=content_type,
|
||||||
|
filename=f"bambuddy-archive-{archive_id}-thumbnail.{extension}",
|
||||||
|
)
|
||||||
|
|
||||||
async def list_archives(
|
async def list_archives(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class Settings(BaseSettings):
|
|||||||
webhook_shared_secret: str | None = None
|
webhook_shared_secret: str | None = None
|
||||||
|
|
||||||
sync_success_only: bool = True
|
sync_success_only: bool = True
|
||||||
|
sync_part_images: bool = True
|
||||||
|
overwrite_part_images: bool = False
|
||||||
default_stock_quantity: Annotated[float, Field(gt=0)] = 1
|
default_stock_quantity: Annotated[float, Field(gt=0)] = 1
|
||||||
inventree_stock_status: int = 10
|
inventree_stock_status: int = 10
|
||||||
part_ipn_prefix: str = "BMB"
|
part_ipn_prefix: str = "BMB"
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class InvenTreeClient:
|
|||||||
headers={
|
headers={
|
||||||
"Authorization": f"Token {settings.inventree_token}",
|
"Authorization": f"Token {settings.inventree_token}",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
},
|
||||||
timeout=settings.http_timeout_seconds,
|
timeout=settings.http_timeout_seconds,
|
||||||
trust_env=False,
|
trust_env=False,
|
||||||
@@ -36,6 +35,9 @@ class InvenTreeClient:
|
|||||||
async def get_stock_location(self) -> dict[str, Any]:
|
async def get_stock_location(self) -> dict[str, Any]:
|
||||||
return await self._request("GET", f"/stock/location/{self.settings.inventree_stock_location_id}/")
|
return await self._request("GET", f"/stock/location/{self.settings.inventree_stock_location_id}/")
|
||||||
|
|
||||||
|
async def get_part(self, part_id: int) -> dict[str, Any]:
|
||||||
|
return await self._request("GET", f"/part/{part_id}/")
|
||||||
|
|
||||||
async def find_part_by_ipn(self, ipn: str) -> dict[str, Any] | None:
|
async def find_part_by_ipn(self, ipn: str) -> dict[str, Any] | None:
|
||||||
for params in ({"IPN": ipn, "limit": 100}, {"search": ipn, "limit": 100}):
|
for params in ({"IPN": ipn, "limit": 100}, {"search": ipn, "limit": 100}):
|
||||||
data = await self._request("GET", "/part/", params=params)
|
data = await self._request("GET", "/part/", params=params)
|
||||||
@@ -79,6 +81,16 @@ class InvenTreeClient:
|
|||||||
}
|
}
|
||||||
return await self._request("POST", "/stock/", json=payload)
|
return await self._request("POST", "/stock/", json=payload)
|
||||||
|
|
||||||
|
async def upload_part_image(self, *, part_id: int, content: bytes, filename: str, content_type: str) -> dict[str, Any]:
|
||||||
|
response = await self.client.patch(
|
||||||
|
f"/part/{part_id}/",
|
||||||
|
files={"image": (filename, content, content_type)},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
body = response.text[:1000]
|
||||||
|
raise ExternalApiError(f"InvenTree PATCH /part/{part_id}/ image failed: HTTP {response.status_code}: {body}")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
async def upsert_part_parameter(
|
async def upsert_part_parameter(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ class ArchiveSyncService:
|
|||||||
|
|
||||||
part_id = await self._get_or_create_part(part_key=part_key, ipn=ipn, name=name, description=description)
|
part_id = await self._get_or_create_part(part_key=part_key, ipn=ipn, name=name, description=description)
|
||||||
await self._sync_part_parameters(part_id=part_id, archive=archive)
|
await self._sync_part_parameters(part_id=part_id, archive=archive)
|
||||||
|
await self._sync_part_image(part_id=part_id, archive=archive)
|
||||||
batch = self.inventree.batch_for_archive(archive.id)
|
batch = self.inventree.batch_for_archive(archive.id)
|
||||||
existing_stock = await self.inventree.find_stock_by_batch(part_id=part_id, batch=batch)
|
existing_stock = await self.inventree.find_stock_by_batch(part_id=part_id, batch=batch)
|
||||||
|
|
||||||
@@ -206,6 +207,27 @@ class ArchiveSyncService:
|
|||||||
for parameter in self.part_parameters_for_archive(archive):
|
for parameter in self.part_parameters_for_archive(archive):
|
||||||
await self.inventree.upsert_part_parameter(part_id=part_id, **parameter)
|
await self.inventree.upsert_part_parameter(part_id=part_id, **parameter)
|
||||||
|
|
||||||
|
async def _sync_part_image(self, *, part_id: int, archive: Archive) -> None:
|
||||||
|
if not self.settings.sync_part_images:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.settings.overwrite_part_images:
|
||||||
|
part = await self.inventree.get_part(part_id)
|
||||||
|
if part.get("image") or part.get("remote_image"):
|
||||||
|
return
|
||||||
|
|
||||||
|
thumbnail = await self.bambuddy.get_archive_thumbnail(archive.id)
|
||||||
|
if not thumbnail:
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.inventree.upload_part_image(
|
||||||
|
part_id=part_id,
|
||||||
|
content=thumbnail.content,
|
||||||
|
filename=thumbnail.filename,
|
||||||
|
content_type=thumbnail.content_type,
|
||||||
|
)
|
||||||
|
logger.info("Synced Bambuddy archive %s thumbnail to InvenTree part %s", archive.id, part_id)
|
||||||
|
|
||||||
def part_parameters_for_archive(self, archive: Archive) -> list[dict[str, str | float | None]]:
|
def part_parameters_for_archive(self, archive: Archive) -> list[dict[str, str | float | None]]:
|
||||||
parameters: list[dict[str, str | float | None]] = []
|
parameters: list[dict[str, str | float | None]] = []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user