Sync Bambuddy thumbnails to InvenTree parts

This commit is contained in:
2026-04-15 14:21:20 +03:00
parent 123100150b
commit beaa210466
6 changed files with 74 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,
*, *,

View File

@@ -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"

View File

@@ -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,
*, *,

View File

@@ -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]] = []