Add Bambuddy InvenTree sync service
This commit is contained in:
276
src/bambuddy_inventree_sync/sync.py
Normal file
276
src/bambuddy_inventree_sync/sync.py
Normal file
@@ -0,0 +1,276 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
|
||||
from .bambuddy import BambuddyClient
|
||||
from .config import Settings
|
||||
from .database import Database
|
||||
from .http_errors import ExternalApiError
|
||||
from .inventree import InvenTreeClient
|
||||
from .models import Archive, SyncResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ArchiveSyncService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
settings: Settings,
|
||||
database: Database,
|
||||
bambuddy: BambuddyClient,
|
||||
inventree: InvenTreeClient,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.database = database
|
||||
self.bambuddy = bambuddy
|
||||
self.inventree = inventree
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def validate_targets(self) -> dict[str, Any]:
|
||||
category, location, bambuddy_info = await asyncio.gather(
|
||||
self.inventree.get_part_category(),
|
||||
self.inventree.get_stock_location(),
|
||||
self.bambuddy.get_system_info(),
|
||||
)
|
||||
return {"part_category": category, "stock_location": location, "bambuddy": bambuddy_info}
|
||||
|
||||
async def sync_archive_id(
|
||||
self,
|
||||
archive_id: int,
|
||||
*,
|
||||
force: bool = False,
|
||||
webhook_data: dict[str, Any] | None = None,
|
||||
) -> SyncResult:
|
||||
async with self._lock:
|
||||
existing = self.database.get_record(archive_id)
|
||||
if existing and existing["sync_status"] == "synced" and not force:
|
||||
return SyncResult(
|
||||
archive_id=archive_id,
|
||||
status="already_synced",
|
||||
message="Archive was already synced",
|
||||
part_id=existing["part_id"],
|
||||
stock_item_id=existing["stock_item_id"],
|
||||
part_key=existing["part_key"],
|
||||
)
|
||||
|
||||
try:
|
||||
archive = await self._load_archive(archive_id, webhook_data)
|
||||
return await self._sync_archive(archive)
|
||||
except Exception as exc:
|
||||
self.database.upsert_record(
|
||||
archive_id=archive_id,
|
||||
sync_status="failed",
|
||||
error=str(exc),
|
||||
raw_archive=webhook_data,
|
||||
)
|
||||
raise
|
||||
|
||||
async def backfill(self, *, status: str | None = None, max_archives: int | None = None) -> dict[str, int]:
|
||||
status_filter = status
|
||||
if status_filter is None and self.settings.sync_success_only:
|
||||
status_filter = "success"
|
||||
|
||||
counts = {"seen": 0, "synced": 0, "already_synced": 0, "skipped": 0, "failed": 0}
|
||||
offset = 0
|
||||
limit = self.settings.backfill_page_size
|
||||
|
||||
while True:
|
||||
total, archives = await self.bambuddy.list_archives(status=status_filter, limit=limit, offset=offset)
|
||||
if not archives:
|
||||
break
|
||||
|
||||
for archive in archives:
|
||||
if max_archives is not None and counts["seen"] >= max_archives:
|
||||
return counts
|
||||
|
||||
counts["seen"] += 1
|
||||
try:
|
||||
result = await self.sync_archive_id(archive.id, webhook_data=archive.model_dump())
|
||||
counts[result.status] = counts.get(result.status, 0) + 1
|
||||
except Exception:
|
||||
logger.exception("Failed to sync Bambuddy archive %s during backfill", archive.id)
|
||||
counts["failed"] += 1
|
||||
|
||||
offset += len(archives)
|
||||
if total is not None and offset >= total:
|
||||
break
|
||||
|
||||
return counts
|
||||
|
||||
async def _load_archive(self, archive_id: int, webhook_data: dict[str, Any] | None) -> Archive:
|
||||
try:
|
||||
return await self.bambuddy.get_archive(archive_id)
|
||||
except ExternalApiError:
|
||||
if webhook_data:
|
||||
data = dict(webhook_data)
|
||||
data.setdefault("id", archive_id)
|
||||
return Archive.model_validate(data)
|
||||
raise
|
||||
|
||||
async def _sync_archive(self, archive: Archive) -> SyncResult:
|
||||
if self.settings.sync_success_only and not self.is_successful_archive(archive):
|
||||
self.database.upsert_record(
|
||||
archive_id=archive.id,
|
||||
sync_status="skipped",
|
||||
archive_status=archive.status,
|
||||
raw_archive=archive.model_dump(),
|
||||
)
|
||||
return SyncResult(archive_id=archive.id, status="skipped", message=f"Archive status is {archive.status}")
|
||||
|
||||
part_key = self.part_key_for_archive(archive)
|
||||
ipn = self.ipn_for_part_key(part_key)
|
||||
name = self.name_for_archive(archive)
|
||||
description = self.description_for_archive(archive)
|
||||
|
||||
part_id = await self._get_or_create_part(part_key=part_key, ipn=ipn, name=name, description=description)
|
||||
batch = self.inventree.batch_for_archive(archive.id)
|
||||
existing_stock = await self.inventree.find_stock_by_batch(part_id=part_id, batch=batch)
|
||||
|
||||
if existing_stock:
|
||||
stock_item_id = int(existing_stock.get("pk") or existing_stock.get("id"))
|
||||
self.database.upsert_record(
|
||||
archive_id=archive.id,
|
||||
sync_status="synced",
|
||||
part_key=part_key,
|
||||
part_id=part_id,
|
||||
stock_item_id=stock_item_id,
|
||||
archive_status=archive.status,
|
||||
raw_archive=archive.model_dump(),
|
||||
)
|
||||
return SyncResult(
|
||||
archive_id=archive.id,
|
||||
status="already_synced",
|
||||
message="Stock item already exists in InvenTree",
|
||||
part_id=part_id,
|
||||
stock_item_id=stock_item_id,
|
||||
part_key=part_key,
|
||||
)
|
||||
|
||||
stock = await self.inventree.create_stock_item(
|
||||
part_id=part_id,
|
||||
archive=archive,
|
||||
notes=self.stock_notes_for_archive(archive),
|
||||
)
|
||||
stock_item_id = int(stock.get("pk") or stock.get("id"))
|
||||
|
||||
self.database.upsert_record(
|
||||
archive_id=archive.id,
|
||||
sync_status="synced",
|
||||
part_key=part_key,
|
||||
part_id=part_id,
|
||||
stock_item_id=stock_item_id,
|
||||
archive_status=archive.status,
|
||||
raw_archive=archive.model_dump(),
|
||||
)
|
||||
logger.info(
|
||||
"Synced Bambuddy archive %s to InvenTree part %s stock item %s",
|
||||
archive.id,
|
||||
part_id,
|
||||
stock_item_id,
|
||||
)
|
||||
return SyncResult(
|
||||
archive_id=archive.id,
|
||||
status="synced",
|
||||
message="Created stock item in InvenTree",
|
||||
part_id=part_id,
|
||||
stock_item_id=stock_item_id,
|
||||
part_key=part_key,
|
||||
)
|
||||
|
||||
async def _get_or_create_part(self, *, part_key: str, ipn: str, name: str, description: str) -> int:
|
||||
cached_part_id = self.database.get_part_id(part_key)
|
||||
if cached_part_id:
|
||||
return cached_part_id
|
||||
|
||||
existing = await self.inventree.find_part_by_ipn(ipn)
|
||||
if existing:
|
||||
part_id = int(existing.get("pk") or existing.get("id"))
|
||||
self.database.upsert_part(part_key=part_key, inventree_part_id=part_id, display_name=name)
|
||||
return part_id
|
||||
|
||||
created = await self.inventree.create_part(name=name, description=description, ipn=ipn)
|
||||
part_id = int(created.get("pk") or created.get("id"))
|
||||
self.database.upsert_part(part_key=part_key, inventree_part_id=part_id, display_name=name)
|
||||
return part_id
|
||||
|
||||
def part_key_for_archive(self, archive: Archive) -> str:
|
||||
parts: list[str] = []
|
||||
raw = archive.model_dump()
|
||||
for field in self.settings.part_key_field_names:
|
||||
value = raw.get(field)
|
||||
if value is not None and str(value).strip():
|
||||
parts.append(str(value).strip().lower())
|
||||
|
||||
if not parts:
|
||||
parts.append(str(archive.id))
|
||||
|
||||
source = "|".join(parts)
|
||||
return hashlib.sha1(source.encode("utf-8")).hexdigest()
|
||||
|
||||
def ipn_for_part_key(self, part_key: str) -> str:
|
||||
return f"{self.settings.part_ipn_prefix}-{part_key[:12]}"
|
||||
|
||||
@staticmethod
|
||||
def name_for_archive(archive: Archive) -> str:
|
||||
if archive.name and archive.name.strip():
|
||||
return archive.name.strip()
|
||||
|
||||
if archive.print_name and archive.print_name.strip():
|
||||
return archive.print_name.strip()
|
||||
|
||||
if archive.filename and archive.filename.strip():
|
||||
filename = archive.filename.strip()
|
||||
if "\\" in filename:
|
||||
return PureWindowsPath(filename).stem or filename
|
||||
return PurePosixPath(filename).stem or filename
|
||||
|
||||
return f"Bambuddy archive {archive.id}"
|
||||
|
||||
def description_for_archive(self, archive: Archive) -> str:
|
||||
return "\n".join(
|
||||
line
|
||||
for line in [
|
||||
"Auto-created from Bambuddy archive.",
|
||||
f"Archive ID: {archive.id}",
|
||||
f"Print name: {archive.print_name}" if archive.print_name else "",
|
||||
f"Filename: {archive.filename}" if archive.filename else "",
|
||||
f"Content hash: {archive.content_hash}" if archive.content_hash else "",
|
||||
f"Printer: {archive.printer_name}" if archive.printer_name else "",
|
||||
f"Created at: {archive.created_at}" if archive.created_at else "",
|
||||
f"Completed at: {archive.completed_at}" if archive.completed_at else "",
|
||||
]
|
||||
if line
|
||||
)
|
||||
|
||||
def stock_notes_for_archive(self, archive: Archive) -> str:
|
||||
duration = archive.actual_time_seconds or archive.print_time_seconds or archive.duration
|
||||
filament_used = archive.filament_used_grams or archive.filament_used
|
||||
rows = [
|
||||
"Imported from Bambuddy.",
|
||||
f"Archive ID: {archive.id}",
|
||||
f"Print name: {archive.print_name}" if archive.print_name else "",
|
||||
f"Filename: {archive.filename}" if archive.filename else "",
|
||||
f"Printer: {archive.printer_name}" if archive.printer_name else "",
|
||||
f"Started at: {archive.started_at}" if archive.started_at else "",
|
||||
f"Completed at: {archive.completed_at}" if archive.completed_at else "",
|
||||
f"Created at: {archive.created_at}" if archive.created_at else "",
|
||||
f"Duration: {duration} seconds" if duration is not None else "",
|
||||
f"Quantity: {archive.quantity}" if archive.quantity is not None else "",
|
||||
f"Object count: {archive.object_count}" if archive.object_count is not None else "",
|
||||
f"Filament used: {filament_used} g" if filament_used is not None else "",
|
||||
f"Filament type: {archive.filament_type}" if archive.filament_type else "",
|
||||
f"Filament color: {archive.filament_color}" if archive.filament_color else "",
|
||||
f"Cost: {archive.cost}" if archive.cost is not None else "",
|
||||
f"Bambuddy status: {archive.status}" if archive.status else "",
|
||||
]
|
||||
return "\n".join(row for row in rows if row)
|
||||
|
||||
@staticmethod
|
||||
def is_successful_archive(archive: Archive) -> bool:
|
||||
status = (archive.status or "").lower()
|
||||
if status in {"success", "completed", "complete", "done"}:
|
||||
return True
|
||||
return bool(archive.completed_at and status not in {"failed", "stopped", "printing", "running"})
|
||||
Reference in New Issue
Block a user