diff --git a/src/asquare.py b/src/asquare.py index 36ec42e..a3bc068 100644 --- a/src/asquare.py +++ b/src/asquare.py @@ -51,10 +51,93 @@ async def get_itm(sku: str): return {"name": name, "id": itm_id} -async def update_itm(lego_set: dict): +async def get_item_by_id(item_id: str) -> dict: + """ + Retrieve full item details by Square catalog item ID. + + Args: + item_id: Square catalog item ID + + Returns: + dict: Item details including name, category_id, variations, etc. + + Raises: + Exception: If item not found or API error + """ + try: + result = await client.catalog.retrieve_catalog_object( + object_id=item_id, include_related_objects=True + ) + result_json = json.loads(result.model_dump_json()) + + if not result_json.get("object"): + raise ValueError(f"Item {item_id} not found") + + obj = result_json["object"] + item_data = obj.get("item_data", {}) + + return { + "id": obj["id"], + "name": item_data.get("name", ""), + "description": item_data.get("description", ""), + "category_id": item_data.get("category_id"), + "variations": item_data.get("variations", []), + "updated_at": obj.get("updated_at"), + "version": obj.get("version"), + } + + except Exception as e: + print(f"[@ERROR] Failed to get item {item_id}: {e}") + raise + + +async def get_item_by_variation_id(variation_id: str) -> dict: + """ + Retrieve parent item details from a variation ID. + + Webhooks send variation IDs (inventory.count.updated), but we need + the parent item to extract the set number from the name. + + Args: + variation_id: Square catalog item variation ID + + Returns: + dict: Parent item details + + Raises: + Exception: If variation/item not found or API error + """ + try: + # Get variation object + result = await client.catalog.retrieve_catalog_object( + object_id=variation_id, include_related_objects=True + ) + result_json = json.loads(result.model_dump_json()) + + if not result_json.get("object"): + raise ValueError(f"Variation {variation_id} not found") + + variation_obj = result_json["object"] + + # Get parent item ID from variation + item_id = variation_obj.get("item_variation_data", {}).get("item_id") + + if not item_id: + raise ValueError(f"No parent item_id found for variation {variation_id}") + + # Retrieve parent item + return await get_item_by_id(item_id) + + except Exception as e: + print(f"[@ERROR] Failed to get item from variation {variation_id}: {e}") + raise + + +async def update_itm(item_id: str, lego_set: dict): """ Asynchronously updates an item in the Square catalog based on the provided LEGO set information. Args: + item_id (str): Square catalog item ID to update lego_set (dict): A dictionary containing LEGO set details from abricksetapi. Expected keys include: - "set_num": LEGO set number (e.g., "75192") - "set_name": Official LEGO set name @@ -69,19 +152,90 @@ async def update_itm(lego_set: dict): KeyError: If expected keys are missing from the input dictionary. Exception: For errors during Square API interaction. """ + try: + set_number = lego_set["set_num"] + set_name = lego_set["set_name"] + set_theme = lego_set["set_theme"] + set_upc = lego_set["set_barcode"] + set_image = lego_set["set_image"] + pw_name = lego_set["pw_name"] + set_adjustedprice = lego_set["set_adjustredprice"] - set_number = lego_set["set_num"] - set_name = lego_set["set_name"] - set_upc = lego_set["set_barcode"] - set_image = lego_set["set_image"] - pw_name = lego_set["pw_name"] - set_adjustedprice = lego_set["set_adjustredprice"] + # Convert price to cents for Square API + price_in_cents = int(set_adjustedprice * 100) - result = await client.catalog.search_items(text_filter=set_number, limit=1) - itm_id = json.loads(result.model_dump_json())["items"][0]["id"] + # Get current item to retrieve variation ID + item = await get_item_by_id(item_id) + variations = item.get("variations", []) - # await sq.catalog.object.upsert() - return True + if not variations: + print( + f"[@WARNING] Item {item_id} has no variations, creating new variation" + ) + variation_id = None + else: + variation_id = variations[0]["id"] + + # Build upsert body + upsert_body = { + "idempotency_key": str(uuid.uuid4()), + "object": { + "type": "ITEM", + "id": item_id, + "item_data": { + "name": pw_name, + "description": f"{set_name} - {set_theme}", + "variations": [ + { + "type": "ITEM_VARIATION", + "id": ( + variation_id if variation_id else "#TEMP_VARIATION_ID" + ), + "item_variation_data": { + "item_id": item_id, + "name": "Regular", + "pricing_type": "FIXED_PRICING", + "price_money": { + "amount": price_in_cents, + "currency": "USD", + }, + "sku": set_upc if set_upc else None, + }, + } + ], + }, + }, + } + + # Remove None values + if not set_upc: + del upsert_body["object"]["item_data"]["variations"][0][ + "item_variation_data" + ]["sku"] + + print(f"[@DEBUG] Upserting item {item_id} with name: {pw_name}") + + # Perform upsert + result = await client.catalog.upsert_catalog_object(body=upsert_body) + result_json = json.loads(result.model_dump_json()) + + if result_json.get("catalog_object"): + print(f"[@DEBUG] ✓ Successfully updated item {item_id}") + # TODO: Handle image upload separately (Square Images API) + # This requires multipart/form-data upload + return True + else: + print( + f"[@ERROR] Failed to update item {item_id}: No catalog_object in response" + ) + return False + + except Exception as e: + print(f"[@ERROR] Failed to update item {item_id}: {e}") + import traceback + + traceback.print_exc() + return False async def acat2id(cat_id: str): diff --git a/src/glue.py b/src/glue.py index 09003bd..67d62b2 100644 --- a/src/glue.py +++ b/src/glue.py @@ -8,34 +8,188 @@ from time import time from . import asquare from . import db from . import abricksetapi -from . import throttler +from . import throttler + Throttler = throttler.GlobalThrottler LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID -# big glue boy -async def do_review() -> bool: - result = await asquare.acat2id(LEGO_CAT_ID) # type: ignore - for item in result: - if not re.match(r".*\d{5}.*", item["name"]): - missing = [] - print(f"{item} is missing its set number!") - #print(f"[@DEBUG] Upserting {item['id']} into REVIEW table") - item_sanitized = { - "sq_object": item["id"], - "set_name": item["name"], - "set_number": None, - "time_added": time - } - missing.append(item_sanitized) - - for item in missing: - print(item) - # sq_item = await db.fetch(item["id"]) - if item["id"] != sq_item["sq_object"]: - print(f"[@DEBUG] {item['id']} is missing)") - #await db.upsert("MISSING", item_sanitized, "sq_object") - # #print(f"[@DEBUG] Upserted {item['id']} into REVIEW table") - #print(f"[@DEBUG] {item['id']} already in REVIEW table") - return True \ No newline at end of file + +async def enrich_single_item(item_id: str, logger=None) -> bool: + """ + Enrich a single Square catalog item with Brickset data. + + Flow: + 1. Get item details from Square (by item_id) + 2. Extract 5-digit set number from name (regex \d{5}) + 3. Fetch Brickset data (with throttling) + 4. Update Square item with enriched data + 5. Handle errors (no set number → MISSING table) + + Args: + item_id: Square catalog item ID + logger: Optional logger instance for debug output + + Returns: + True if successful, False otherwise + """ + try: + if logger: + logger.info(f"Starting enrichment for item {item_id}") + else: + print(f"[@DEBUG] Starting enrichment for item {item_id}") + + # Get item details from Square + item = await asquare.get_item_by_id(item_id) + item_name = item.get("name", "") + + if logger: + logger.debug(f"Item name: {item_name}") + else: + print(f"[@DEBUG] Item name: {item_name}") + + # Extract 5-digit set number + set_numbers = re.findall(r"\d{5}", item_name) + + if not set_numbers: + # No set number found - log to MISSING table + if logger: + logger.warning(f"No set number found in item {item_id}: {item_name}") + else: + print(f"[@WARNING] No set number found in item {item_id}: {item_name}") + + await db.upsert( + "MISSING", + { + "sq_object": item_id, + "time_added": int(time()), + "set_number": None, + "set_name": item_name, + }, + "sq_object", + ) + + return False + + set_number = set_numbers[0] + + if logger: + logger.info(f"Extracted set number: {set_number}") + else: + print(f"[@DEBUG] Extracted set number: {set_number}") + + # Fetch Brickset data (with throttling) + brickset_client = abricksetapi.AsyncBricksetAPI(debug=True, logger=logger) + lego_set = await brickset_client.throttled_get_set(set_number) + + if logger: + logger.info(f"Retrieved Brickset data for {lego_set['pw_name']}") + else: + print(f"[@DEBUG] Retrieved Brickset data for {lego_set['pw_name']}") + + # Check if manual price review needed + if lego_set.get("requires_manual_price"): + if logger: + logger.info(f"Item {item_id} requires manual price review (≥$110)") + else: + print(f"[@DEBUG] Item {item_id} requires manual price review") + + await db.upsert( + "REVIEW", + { + "sq_object": item_id, + "price": lego_set["set_adjustredprice"], + "date_modified": int(time()), + }, + "sq_object", + ) + + # Update Square item with enriched data + success = await asquare.update_itm(item_id, lego_set) + + if success: + if logger: + logger.info(f"✓ Successfully enriched item {item_id}") + else: + print(f"[@DEBUG] ✓ Successfully enriched item {item_id}") + return True + else: + if logger: + logger.error(f"Failed to update Square item {item_id}") + else: + print(f"[@ERROR] Failed to update Square item {item_id}") + return False + + except ValueError as e: + if logger: + logger.error(f"Value error enriching item {item_id}: {e}") + else: + print(f"[@ERROR] Value error enriching item {item_id}: {e}") + return False + + except Exception as e: + if logger: + logger.error(f"Unexpected error enriching item {item_id}: {e}") + else: + print(f"[@ERROR] Unexpected error enriching item {item_id}: {e}") + import traceback + + traceback.print_exc() + return False + + +async def enrich_bulk(logger=None) -> None: + """ + Enrich all items in the LEGO category (bulk operation). + + This is the refactored RUNUPDATE command logic. + Iterates all items in LEGO category and enriches each one. + + Args: + logger: Optional logger instance for debug output + """ + try: + if logger: + logger.info("Starting bulk enrichment for LEGO category") + else: + print(f"[@DEBUG] Starting bulk enrichment for LEGO category {LEGO_CAT_ID}") + + # Get all items in LEGO category + items = await asquare.acat2id(LEGO_CAT_ID) + + if logger: + logger.info(f"Found {len(items)} items in LEGO category") + else: + print(f"[@DEBUG] Found {len(items)} items in LEGO category") + + # Enrich each item + successful = 0 + failed = 0 + + for item in items: + item_id = item["id"] + result = await enrich_single_item(item_id, logger=logger) + + if result: + successful += 1 + else: + failed += 1 + + if logger: + logger.info( + f"Bulk enrichment complete: {successful} successful, {failed} failed" + ) + else: + print( + f"[@DEBUG] Bulk enrichment complete: {successful} successful, {failed} failed" + ) + + except Exception as e: + if logger: + logger.error(f"Error during bulk enrichment: {e}") + else: + print(f"[@ERROR] Error during bulk enrichment: {e}") + import traceback + + traceback.print_exc() diff --git a/studspider.py b/studspider.py index 7d376ad..e1c98dd 100644 --- a/studspider.py +++ b/studspider.py @@ -12,6 +12,8 @@ from square.utils.webhooks_helper import verify_signature # Configuration SQUARE_WEBHOOK_SIGNATURE_KEY = sq_env.SQUARE_WEBHOOK_SIGNATURE_KEY SQUARE_WEBHOOK_URL = sq_env.SQUARE_WEBHOOK_URL +SQUARE_LEGO_CAT_ID = sq_env.LEGO_CAT_ID + # Debug: Print configuration (redact key) if SQUARE_WEBHOOK_SIGNATURE_KEY: @@ -233,17 +235,77 @@ async def process_webhook( inventory_counts = ( data.get("data", {}).get("object", {}).get("inventory_counts", []) ) + + # Import here to avoid circular imports + from src import asquare, glue + for count in inventory_counts: - catalog_id = count.get("catalog_object_id") + variation_id = count.get("catalog_object_id") quantity = count.get("quantity") state = count.get("state") print( - f"[PROCESS] Catalog: {catalog_id}, Qty: {quantity}, State: {state}" + f"[PROCESS] Catalog: {variation_id}, Qty: {quantity}, State: {state}" ) + try: + # Get parent item from variation ID + item = await asquare.get_item_by_variation_id(variation_id) + item_id = item.get("id") + item_name = item.get("name", "") + category_id = item.get("category_id") + + print(f"[PROCESS] Item: {item_id} - {item_name}") + print(f"[PROCESS] Category: {category_id}") + + # Only process LEGO category items + if category_id == SQUARE_LEGO_CAT_ID: + print( + f"[PROCESS] ✓ LEGO item detected - triggering enrichment" + ) + # Trigger enrichment in background + asyncio.create_task(glue.enrich_single_item(item_id)) + else: + print(f"[PROCESS] ✗ Not a LEGO item - skipping enrichment") + + except Exception as e: + print(f"[ERROR] Failed to process variation {variation_id}: {e}") + elif webhook_type == "catalog.version.updated": print(f"[PROCESS] Processing catalog version update...") - # Add your catalog processing logic here + + # Import here to avoid circular imports + from src import asquare, glue + + catalog_object = data.get("data", {}).get("object", {}) + object_type = catalog_object.get("type") + + if object_type == "ITEM": + item_id = catalog_object.get("id") + print(f"[PROCESS] Item updated: {item_id}") + + try: + # Get item details to check category + item = await asquare.get_item_by_id(item_id) + item_name = item.get("name", "") + category_id = item.get("category_id") + + print(f"[PROCESS] Item: {item_id} - {item_name}") + print(f"[PROCESS] Category: {category_id}") + + # Only process LEGO category items + if category_id == SQUARE_LEGO_CAT_ID: + print( + f"[PROCESS] ✓ LEGO item detected - triggering enrichment" + ) + # Trigger enrichment in background + asyncio.create_task(glue.enrich_single_item(item_id)) + else: + print(f"[PROCESS] ✗ Not a LEGO item - skipping enrichment") + + except Exception as e: + print(f"[ERROR] Failed to process item {item_id}: {e}") + else: + print(f"[PROCESS] Object type '{object_type}' - skipping") else: print(f"[PROCESS] Webhook type '{webhook_type}' - no handler configured") @@ -252,6 +314,9 @@ async def process_webhook( except Exception as e: print(f"[ERROR] Failed to process webhook {event_id}: {e}") + import traceback + + traceback.print_exc() if __name__ == "__main__":