Compare commits
26
Commits
94af7d9d89
...
rewrite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcccdcd9ec | ||
|
|
dbc1115249 | ||
|
|
dbe5cd6146 | ||
|
|
a6e2397046 | ||
|
|
2da61539cc | ||
|
|
bf65965b26 | ||
|
|
bcf625d2b0 | ||
|
|
dfd2c2f07d | ||
|
|
ab3297acfb | ||
|
|
90aca6cca4 | ||
|
|
199995b947 | ||
|
|
2f7769e2c3 | ||
|
|
7ca181bdd6 | ||
|
|
1e96dead07 | ||
|
|
248950adb9 | ||
|
|
832122e63c | ||
|
|
e7f10c4f59 | ||
|
|
2de0843344 | ||
|
|
daf6f215fe | ||
|
|
7fbf415acf | ||
|
|
80542de333 | ||
|
|
a11aba2564 | ||
|
|
b40526c494 | ||
|
|
803a4412c1 | ||
|
|
3dfd6a27b9 | ||
|
|
ce32abf58c |
No files matched your search
+10
-3
@@ -6,17 +6,24 @@ BRICKSET_API_KEY = "your_brickset_api_key"
|
||||
# Square environment - "sandbox" for testing, "production" for live store
|
||||
SQUARE_ENV = "sandbox" # or "production"
|
||||
|
||||
SQUARE_WEBHOOK_URL = "https://yourdomain.com/hook"
|
||||
# Public webhook URL that Square will POST events to
|
||||
SQUARE_WEBHOOK_URL = "https://yourdomain.com/webhook"
|
||||
|
||||
# Sandbox credentials - test environment (fake data, safe for development)
|
||||
SANDBOX_SQUARE_APP_ID = "your_sandbox_app_id"
|
||||
SANDBOX_SQUARE_TOKEN = "your_sandbox_access_token"
|
||||
SANDBOX_SQUARE_WEBHOOK_INVENTORY_ID = "your_sandbox_webhook_signature_key"
|
||||
|
||||
# Sandbox webhook subscription ID and signature key for verifying webhook requests
|
||||
SANDBOX_SQUARE_WEBHOOK_SUB_ID = "your_sandbox_webhook_subscription_id"
|
||||
SANDBOX_SQUARE_WEBHOOK_SIGNATURE_KEY = "your_sandbox_webhook_signature_key"
|
||||
|
||||
# Production credentials - live store (real inventory and sales)
|
||||
PROD_SQUARE_APP_ID = "your_production_app_id"
|
||||
PROD_SQUARE_TOKEN = "your_production_access_token"
|
||||
PROD_SQUARE_WEBHOOK_INVENTORY_ID = "your_production_webhook_signature_key"
|
||||
|
||||
# Production webhook subscription ID and signature key for verifying webhook requests
|
||||
PROD_SQUARE_WEBHOOK_SUB_ID = "your_production_webhook_subscription_id"
|
||||
PROD_SQUARE_WEBHOOK_SIGNATURE_KEY = "your_production_webhook_signature_key"
|
||||
|
||||
# Category ID for LEGO items in Square catalog (find in Dashboard → Items → Categories)
|
||||
SQUARE_LEGO_CAT_ID = "your_lego_category_id"
|
||||
+31
-9
@@ -1,5 +1,17 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
import git
|
||||
import src.banner as banner
|
||||
|
||||
|
||||
def get_git_commit():
|
||||
"""Get first 6 characters of current git commit hash"""
|
||||
try:
|
||||
repo = git.Repo(search_parent_directories=True)
|
||||
return repo.head.commit.hexsha[:6]
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# Server socket
|
||||
bind = "127.0.0.1:7433" # nginx will proxy to this
|
||||
@@ -52,16 +64,15 @@ def on_starting(server):
|
||||
"""Called just before the master process is initialized."""
|
||||
print("Studspider WEB starting...")
|
||||
|
||||
# Load .env file before workers start
|
||||
import dotenv
|
||||
# Verify .env file exists (workers will load it via get_sq_env.py)
|
||||
from pathlib import Path
|
||||
|
||||
env_path = Path(__file__).parent.parent / ".env"
|
||||
print(f"[@DEBUG] Looking for .env at: {env_path.absolute()}")
|
||||
if env_path.exists():
|
||||
dotenv.load_dotenv(env_path)
|
||||
print(f"✓ Loaded environment from {env_path}")
|
||||
print(f"✓ .env file found (workers will load via get_sq_env.py)")
|
||||
else:
|
||||
print(f"⚠ No .env file found at {env_path}")
|
||||
print(f"⚠ No .env file found at {env_path.absolute()}")
|
||||
|
||||
|
||||
def on_reload(server):
|
||||
@@ -70,10 +81,21 @@ def on_reload(server):
|
||||
|
||||
|
||||
def when_ready(server):
|
||||
"""Called just after the server is started."""
|
||||
print(f"STUDSPIDER HOOK v0.1 RUNNING ON {bind}")
|
||||
print(f"WORKERS: {workers}")
|
||||
print(f"WORKING CLASS: {worker_class}\n")
|
||||
commit_hash = get_git_commit()
|
||||
banner_text = banner.generate("studspider v0.3.0")
|
||||
|
||||
# Add commit hash to the last line of banner
|
||||
banner_lines = banner_text.rstrip("\n").split("\n")
|
||||
if banner_lines:
|
||||
banner_lines[-1] += f" - {commit_hash}"
|
||||
|
||||
print("\n".join(banner_lines))
|
||||
print("Built for PLAY-WELL TEKNOLOGIES by contact@studspider.sh\n\n")
|
||||
print("PRODUCTION MODE\n" if os.getenv("SQUARE_ENV") == "production" else "SANDBOX MODE\n")
|
||||
print(f"HOST:\t{bind}")
|
||||
print(f"WORKERS:\t{workers}")
|
||||
print(f"WORKER TYPE:\t{worker_class}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
|
||||
def worker_int(worker):
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "studpider"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
description = ""
|
||||
authors = [{ name = "CF", email = "ph0sgene@pm.me" }]
|
||||
requires-python = ">=3.13,<3.14"
|
||||
@@ -21,6 +21,8 @@ dependencies = [
|
||||
"fastapi>=0.128.0",
|
||||
"uvicorn>=0.40.0",
|
||||
"gunicorn>=23.0.0",
|
||||
"async-lru>=2.0.4",
|
||||
"gitpython>=3.1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -2,7 +2,7 @@ from setuptools import setup
|
||||
|
||||
setup(
|
||||
name="studspider",
|
||||
version="0.1",
|
||||
version="0.3.0",
|
||||
description="Bricket + Square integration application",
|
||||
author="CF",
|
||||
author_email="chris-forst@pm.me",
|
||||
|
||||
+132
-14
@@ -7,9 +7,13 @@ import asyncio
|
||||
import os
|
||||
import aiohttp
|
||||
import json
|
||||
import math
|
||||
import dotenv
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from async_lru import alru_cache
|
||||
from . import throttler
|
||||
from . import get_sq_env
|
||||
|
||||
Throttler = throttler.GlobalThrottler
|
||||
|
||||
@@ -19,16 +23,115 @@ dotenv.load_dotenv()
|
||||
class AsyncBricksetAPI:
|
||||
"""Asynchronous client for the Brickset API"""
|
||||
|
||||
def __init__(self, debug: bool = False, logger=None):
|
||||
def __init__(self, debug: bool = None, logger=None):
|
||||
self.username = os.getenv("BRICKSET_USERNAME")
|
||||
self.password = os.getenv("BRICKSET_PASS")
|
||||
self.api_key = os.getenv("BRICKSET_API_KEY")
|
||||
self.debug = debug
|
||||
# Use DEBUG_MODE from env if debug not explicitly set
|
||||
self.debug = debug if debug is not None else get_sq_env.DEBUG_MODE
|
||||
self.headers = {"UserAgent": "studspider/0.1"}
|
||||
self.throttler = Throttler(rate_limit=500, period=60)
|
||||
self.user_hash = None
|
||||
self.logger = logger
|
||||
|
||||
# API usage tracking
|
||||
self.daily_requests_used: Optional[int] = None
|
||||
self.daily_requests_limit = 1000
|
||||
self.usage_last_checked: Optional[datetime] = None
|
||||
self.usage_check_interval = timedelta(hours=1) # Check usage every hour
|
||||
|
||||
async def get_key_usage_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch API key usage statistics from Brickset.
|
||||
|
||||
Returns:
|
||||
dict: Usage stats including dateStamp and apiCalls
|
||||
"""
|
||||
url = "https://brickset.com/api/v3.asmx/getKeyUsageStats"
|
||||
params = {"apiKey": self.api_key}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, params=params, headers=self.headers
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.read()
|
||||
result = json.loads(data.decode("utf-8"))
|
||||
|
||||
if self.debug:
|
||||
print(f"[@DEBUG] API usage stats: {result}")
|
||||
if self.logger:
|
||||
self.logger.debug(f"API usage stats: {result}")
|
||||
|
||||
return result
|
||||
else:
|
||||
error_msg = f"Failed to get usage stats: HTTP {response.status}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise Exception(error_msg)
|
||||
|
||||
async def check_and_update_usage(self) -> bool:
|
||||
"""
|
||||
Check current API usage and update internal counter.
|
||||
Automatically called before each request if needed.
|
||||
|
||||
Returns:
|
||||
bool: True if under limit, False if at/over limit
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
# Check usage if we haven't checked yet or if it's been over an hour
|
||||
should_check = (
|
||||
self.daily_requests_used is None
|
||||
or self.usage_last_checked is None
|
||||
or (now - self.usage_last_checked) > self.usage_check_interval
|
||||
)
|
||||
|
||||
if should_check:
|
||||
try:
|
||||
stats = await self.get_key_usage_stats()
|
||||
# Brickset returns {"status": "success", "apiKeyUsage": [...]}
|
||||
usage_array = stats.get("apiKeyUsage", [])
|
||||
if usage_array and len(usage_array) > 0:
|
||||
today_stats = usage_array[0] # Most recent day
|
||||
self.daily_requests_used = today_stats.get("count", 0)
|
||||
self.usage_last_checked = now
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"API usage: {self.daily_requests_used}/{self.daily_requests_limit}"
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
print(
|
||||
f"[@DEBUG] API usage: {self.daily_requests_used}/{self.daily_requests_limit}"
|
||||
)
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.warning(f"Failed to check API usage: {e}")
|
||||
# Don't block requests if usage check fails
|
||||
return True
|
||||
|
||||
# Check if we're over the limit
|
||||
if self.daily_requests_used is not None:
|
||||
if self.daily_requests_used >= self.daily_requests_limit:
|
||||
error_msg = f"Daily API limit reached: {self.daily_requests_used}/{self.daily_requests_limit}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
if self.debug:
|
||||
print(f"[@ERROR] {error_msg}")
|
||||
return False
|
||||
|
||||
# Warn when approaching limit
|
||||
if self.daily_requests_used >= self.daily_requests_limit * 0.9:
|
||||
warning_msg = f"Approaching daily API limit: {self.daily_requests_used}/{self.daily_requests_limit}"
|
||||
if self.logger:
|
||||
self.logger.warning(warning_msg)
|
||||
if self.debug:
|
||||
print(f"[@WARNING] {warning_msg}")
|
||||
|
||||
return True
|
||||
|
||||
async def login(self) -> Dict[str, Any]:
|
||||
"""Asynchronously authenticate with Brickset API"""
|
||||
url = "https://brickset.com/api/v3.asmx/login"
|
||||
@@ -55,16 +158,27 @@ class AsyncBricksetAPI:
|
||||
self.logger.info("Successfully authenticated with Brickset API")
|
||||
return result
|
||||
|
||||
@alru_cache(maxsize=1000, ttl=604800) # Cache 1000 sets, 7 days TTL
|
||||
async def get_set(self, set_number: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch LEGO set information asynchronously
|
||||
Fetch LEGO set information asynchronously (cached for 7 days)
|
||||
|
||||
Args:
|
||||
set_number (str): LEGO set number
|
||||
|
||||
Returns:
|
||||
Dict containing set information
|
||||
|
||||
Raises:
|
||||
Exception: If daily API limit exceeded or API error
|
||||
"""
|
||||
# Check API usage before making request
|
||||
if not await self.check_and_update_usage():
|
||||
raise Exception(
|
||||
f"Brickset API daily limit reached ({self.daily_requests_limit} requests/day). "
|
||||
"Please try again tomorrow."
|
||||
)
|
||||
|
||||
if not self.user_hash:
|
||||
await self.login()
|
||||
|
||||
@@ -101,26 +215,30 @@ class AsyncBricksetAPI:
|
||||
)
|
||||
|
||||
set_data = result["sets"][0]
|
||||
|
||||
# Try to get UPC barcode, fall back to EAN if not available
|
||||
barcode_data = set_data.get("barcode", {})
|
||||
upc_code = barcode_data.get("UPC") or barcode_data.get("EAN")
|
||||
|
||||
# Get retail price before rounding
|
||||
retail_price = set_data["LEGOCom"]["US"]["retailPrice"]
|
||||
|
||||
our_set = {
|
||||
"set_num": set_data["number"],
|
||||
"set_name": set_data["name"],
|
||||
"set_theme": set_data["theme"],
|
||||
"set_image": set_data["image"]["thumbnailURL"],
|
||||
"set_barcode": set_data["barcode"]["UPC"],
|
||||
"set_retailprice": round(
|
||||
set_data["LEGOCom"]["US"]["retailPrice"]
|
||||
),
|
||||
"set_barcode": upc_code,
|
||||
"set_retailprice": round(retail_price),
|
||||
}
|
||||
|
||||
# Calculate adjusted price (10% markup)
|
||||
retail_price = our_set["set_retailprice"]
|
||||
our_set["set_adjustredprice"] = retail_price + (
|
||||
(10 / 100) * retail_price
|
||||
)
|
||||
# Calculate adjusted price (10% markup), then round up
|
||||
adjusted_price = retail_price * 1.10
|
||||
our_set["set_adjustredprice"] = math.ceil(adjusted_price)
|
||||
|
||||
# Set PW inventory name with theme
|
||||
our_set["pw_name"] = (
|
||||
f"{our_set['set_num']} - {our_set['set_theme'].upper()} {our_set['set_name']}"
|
||||
f"{our_set['set_num']} - {our_set['set_theme'].upper()} - {our_set['set_name']}"
|
||||
)
|
||||
|
||||
# Flag for manual price review if needed
|
||||
|
||||
+138
-35
@@ -4,7 +4,10 @@ import json
|
||||
import asyncio
|
||||
import uuid
|
||||
import re
|
||||
import traceback
|
||||
from time import time
|
||||
import aiohttp
|
||||
from io import BytesIO
|
||||
|
||||
from square.core.api_error import ApiError
|
||||
|
||||
@@ -121,8 +124,9 @@ async def get_item_by_variation_id(variation_id: str) -> dict:
|
||||
|
||||
cursor = None
|
||||
page_count = 0
|
||||
max_pages = 50 # Safety limit to prevent infinite loops
|
||||
|
||||
while True:
|
||||
while page_count < max_pages:
|
||||
page_count += 1
|
||||
print(f"[@DEBUG] Searching page {page_count}...")
|
||||
|
||||
@@ -148,6 +152,7 @@ async def get_item_by_variation_id(variation_id: str) -> dict:
|
||||
raise ValueError(f"No items found in LEGO category")
|
||||
else:
|
||||
# No more results
|
||||
print(f"[@DEBUG] No more results on page {page_count}")
|
||||
break
|
||||
|
||||
# Search through items on this page to find which has this variation
|
||||
@@ -185,8 +190,15 @@ async def get_item_by_variation_id(variation_id: str) -> dict:
|
||||
cursor = result_json.get("cursor")
|
||||
if not cursor:
|
||||
# No more pages
|
||||
print(f"[@DEBUG] No more pages after page {page_count}")
|
||||
break
|
||||
|
||||
# If we get here, variation wasn't found
|
||||
if page_count >= max_pages:
|
||||
raise ValueError(
|
||||
f"Variation {variation_id} not found after searching {max_pages} pages (possible infinite loop prevented)"
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Variation {variation_id} not found in any LEGO category item (searched {page_count} pages)"
|
||||
)
|
||||
@@ -196,6 +208,72 @@ async def get_item_by_variation_id(variation_id: str) -> dict:
|
||||
raise
|
||||
|
||||
|
||||
async def upload_item_image(item_id: str, image_url: str) -> bool:
|
||||
"""
|
||||
Download image from URL and upload to Square catalog item.
|
||||
|
||||
Args:
|
||||
item_id: Square catalog item ID
|
||||
image_url: URL to download image from (e.g., Brickset thumbnail)
|
||||
|
||||
Returns:
|
||||
bool: True if upload successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
print(f"[@DEBUG] Downloading image from {image_url}")
|
||||
|
||||
# Download image from URL
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(image_url) as resp:
|
||||
if resp.status != 200:
|
||||
print(f"[@ERROR] Failed to download image: HTTP {resp.status}")
|
||||
return False
|
||||
|
||||
image_data = await resp.read()
|
||||
|
||||
# Determine content type from URL extension
|
||||
content_type = "image/jpeg"
|
||||
if image_url.lower().endswith(".png"):
|
||||
content_type = "image/png"
|
||||
elif image_url.lower().endswith(".gif"):
|
||||
content_type = "image/gif"
|
||||
|
||||
print(
|
||||
f"[@DEBUG] Uploading {len(image_data)} bytes as {content_type} to item {item_id}"
|
||||
)
|
||||
|
||||
# Upload to Square
|
||||
result = await client.catalog.images.create(
|
||||
image_file=image_data,
|
||||
request={
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
"image": {
|
||||
"type": "IMAGE",
|
||||
"id": f"#IMAGE_{item_id}",
|
||||
"image_data": {
|
||||
"name": f"LEGO Set Image",
|
||||
},
|
||||
},
|
||||
"object_id": item_id,
|
||||
},
|
||||
image_file_name=f"lego_{item_id}.jpg",
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
|
||||
if result_json.get("image"):
|
||||
print(f"[@DEBUG] ✓ Successfully uploaded image for item {item_id}")
|
||||
return True
|
||||
else:
|
||||
print(f"[@ERROR] Image upload failed: {result_json}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"[@ERROR] Failed to upload image for item {item_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
|
||||
"""
|
||||
Asynchronously updates an item in the Square catalog based on the provided LEGO set information.
|
||||
@@ -225,7 +303,6 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
|
||||
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)
|
||||
|
||||
# Retry logic for version conflicts (race conditions with concurrent updates)
|
||||
@@ -244,7 +321,7 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
|
||||
print(f"[@WARNING] {e} - skipping update (item may have been deleted)")
|
||||
return
|
||||
|
||||
variations = fresh_item.get("variations", [])
|
||||
variations = fresh_item.get("item_data", {}).get("variations", [])
|
||||
|
||||
if not variations:
|
||||
print(
|
||||
@@ -286,32 +363,11 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
|
||||
print(f"[@DEBUG] Fresh variation version: {variation_version}")
|
||||
print(f"[@DEBUG] Final category_id for upsert: {category_id}")
|
||||
|
||||
# Build upsert body
|
||||
variation_obj = {
|
||||
"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_number, # LEGO set number (e.g., "10338")
|
||||
"upc": set_upc if set_upc else None, # Barcode/GTIN
|
||||
},
|
||||
}
|
||||
|
||||
# Include variation version if it exists (required for updates)
|
||||
if variation_version is not None:
|
||||
variation_obj["version"] = variation_version
|
||||
|
||||
# Get the full existing item_data and modify only what we need
|
||||
# This preserves ALL existing fields (location settings, tax, etc)
|
||||
existing_item_data = fresh_item.get("item_data", {})
|
||||
|
||||
# Update only the fields we want to change
|
||||
# Update item-level fields
|
||||
existing_item_data["name"] = pw_name
|
||||
existing_item_data["description"] = f"{set_name} - {set_theme}"
|
||||
existing_item_data["category_id"] = category_id
|
||||
@@ -323,16 +379,62 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
|
||||
|
||||
for existing_var in existing_variations:
|
||||
if existing_var.get("id") == variation_id:
|
||||
# Replace this variation with our updated one
|
||||
updated_variations.append(variation_obj)
|
||||
# Preserve all variation fields and only update what we need
|
||||
updated_var = existing_var.copy()
|
||||
updated_var_data = existing_var.get(
|
||||
"item_variation_data", {}
|
||||
).copy()
|
||||
|
||||
# Check if this variation has VARIABLE_PRICING
|
||||
current_pricing_type = updated_var_data.get("pricing_type")
|
||||
|
||||
# Only set price if not using variable pricing
|
||||
if current_pricing_type != "VARIABLE_PRICING":
|
||||
updated_var_data["price_money"] = {
|
||||
"amount": price_in_cents,
|
||||
"currency": "USD",
|
||||
}
|
||||
print(f"[@DEBUG] Setting price: ${set_adjustedprice} for variation {variation_id}")
|
||||
else:
|
||||
print(f"[@WARNING] Skipping price update for {variation_id} - has VARIABLE_PRICING enabled")
|
||||
|
||||
updated_var_data["sku"] = set_number # LEGO set number
|
||||
updated_var_data["upc"] = (
|
||||
set_upc if set_upc else None
|
||||
) # Barcode/GTIN
|
||||
|
||||
updated_var["item_variation_data"] = updated_var_data
|
||||
|
||||
# Update version (required for updates)
|
||||
if variation_version is not None:
|
||||
updated_var["version"] = variation_version
|
||||
|
||||
updated_variations.append(updated_var)
|
||||
variation_found = True
|
||||
else:
|
||||
# Keep other variations as-is
|
||||
# Keeprice_money": {
|
||||
"amount": price_in_cents,
|
||||
"currency": "USD",
|
||||
},
|
||||
"p other variations as-is
|
||||
updated_variations.append(existing_var)
|
||||
|
||||
# If variation wasn't found (shouldn't happen), add it
|
||||
if not variation_found and variation_id:
|
||||
updated_variations.append(variation_obj)
|
||||
# If variation wasn't found (shouldn't happen), create new one
|
||||
if not variation_found:
|
||||
new_variation = {
|
||||
"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",
|
||||
"sku": set_number,
|
||||
"upc": set_upc if set_upc else None,
|
||||
},
|
||||
}
|
||||
if variation_version is not None:
|
||||
new_variation["version"] = variation_version
|
||||
updated_variations.append(new_variation)
|
||||
|
||||
existing_item_data["variations"] = updated_variations
|
||||
|
||||
@@ -373,8 +475,11 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
|
||||
)
|
||||
print(f"[@DEBUG] Square returned category_id: {returned_category}")
|
||||
|
||||
# TODO: Handle image upload separately (Square Images API)
|
||||
# This requires multipart/form-data upload
|
||||
# Upload image if available (DISABLED - concerns about storage costs)
|
||||
# if set_image:
|
||||
# print(f"[@DEBUG] Uploading image from {set_image}")
|
||||
# await upload_item_image(item_id, set_image)
|
||||
|
||||
return True
|
||||
else:
|
||||
print(
|
||||
@@ -404,8 +509,6 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
|
||||
|
||||
except Exception as e:
|
||||
print(f"[@ERROR] Failed to update item {item_id}: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
+10
-3
@@ -1,14 +1,21 @@
|
||||
import os
|
||||
import dotenv
|
||||
from pathlib import Path
|
||||
from square.environment import SquareEnvironment
|
||||
|
||||
dotenv.load_dotenv()
|
||||
# Load .env from project root (parent of src/)
|
||||
env_path = Path(__file__).parent.parent / ".env"
|
||||
dotenv.load_dotenv(env_path)
|
||||
|
||||
LEGO_CAT_ID = os.getenv("SQUARE_LEGO_CAT_ID")
|
||||
|
||||
SQUARE_WEBHOOK_URL = os.getenv("SQUARE_WEBHOOK_URL")
|
||||
|
||||
if os.getenv("SQUARE_ENV") == "production":
|
||||
# Check environment mode
|
||||
IS_PRODUCTION = os.getenv("SQUARE_ENV") == "production"
|
||||
DEBUG_MODE = not IS_PRODUCTION # Debug logging enabled in sandbox/dev
|
||||
|
||||
if IS_PRODUCTION:
|
||||
SQUARE_APP_ID = os.getenv("PROD_SQUARE_APP_ID")
|
||||
SQUARE_TOKEN = os.getenv("PROD_SQUARE_TOKEN")
|
||||
SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("PROD_SQUARE_WEBHOOK_SIGNATURE_KEY")
|
||||
@@ -19,4 +26,4 @@ else:
|
||||
SQUARE_TOKEN = os.getenv("SANDBOX_SQUARE_TOKEN")
|
||||
SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("SANDBOX_SQUARE_WEBHOOK_SIGNATURE_KEY")
|
||||
SQUARE_WEBHOOK_SUB_ID = os.getenv("SANDBOX_SQUARE_WEBHOOK_SUB_ID")
|
||||
environment = SquareEnvironment.SANDBOX
|
||||
environment = SquareEnvironment.SANDBOX
|
||||
+31
-25
@@ -96,7 +96,8 @@ async def enrich_single_item(item_id: str, item_data: dict = None, logger=None)
|
||||
print(f"[@DEBUG] Extracted set number: {set_number}")
|
||||
|
||||
# Fetch Brickset data (with throttling)
|
||||
brickset_client = abricksetapi.AsyncBricksetAPI(debug=True, logger=logger)
|
||||
# debug flag will be auto-set based on SQUARE_ENV
|
||||
brickset_client = abricksetapi.AsyncBricksetAPI(logger=logger)
|
||||
lego_set = await brickset_client.throttled_get_set(set_number)
|
||||
|
||||
if logger:
|
||||
@@ -104,24 +105,10 @@ async def enrich_single_item(item_id: str, item_data: dict = None, logger=None)
|
||||
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")
|
||||
# Note: Price updates disabled - enriching without price
|
||||
# REVIEW table logic skipped
|
||||
|
||||
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
|
||||
# Update Square item with enriched data (without price)
|
||||
success = await asquare.update_itm(item_id, lego_set, item_data=item)
|
||||
|
||||
if success:
|
||||
@@ -179,18 +166,37 @@ async def enrich_bulk(logger=None) -> None:
|
||||
else:
|
||||
print(f"[@DEBUG] Found {len(items)} items in LEGO category")
|
||||
|
||||
# Enrich each item
|
||||
# Enrich each item with concurrent batches
|
||||
successful = 0
|
||||
failed = 0
|
||||
batch_size = 20 # Process 20 items concurrently
|
||||
|
||||
for item in items:
|
||||
item_id = item["id"]
|
||||
result = await enrich_single_item(item_id, logger=logger)
|
||||
for i in range(0, len(items), batch_size):
|
||||
batch = items[i : i + batch_size]
|
||||
batch_num = (i // batch_size) + 1
|
||||
total_batches = (len(items) + batch_size - 1) // batch_size
|
||||
|
||||
if result:
|
||||
successful += 1
|
||||
if logger:
|
||||
logger.info(
|
||||
f"Processing batch {batch_num}/{total_batches} ({len(batch)} items)"
|
||||
)
|
||||
else:
|
||||
failed += 1
|
||||
print(
|
||||
f"[@DEBUG] Processing batch {batch_num}/{total_batches} ({len(batch)} items)"
|
||||
)
|
||||
|
||||
# Process batch concurrently
|
||||
tasks = [enrich_single_item(item["id"], logger=logger) for item in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Count results
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
failed += 1
|
||||
elif result:
|
||||
successful += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
if logger:
|
||||
logger.info(
|
||||
|
||||
@@ -1,120 +1,120 @@
|
||||
import aiohttp
|
||||
import json
|
||||
import dotenv
|
||||
import os
|
||||
import importlib.metadata
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
api_key = os.getenv("BRICKOWL")
|
||||
abrickowl_version = importlib.metadata.version("abrickowlapi")
|
||||
|
||||
|
||||
class ABrickowlAPI:
|
||||
|
||||
def __init__(self, value):
|
||||
self.user_agent = f"abrickowlapi/{abrickowl_version}"
|
||||
self.headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||
self.base_url = "https://api.brickowl.com/v1/"
|
||||
self.session = aiohttp.ClientSession()
|
||||
return self
|
||||
|
||||
def set_valid(self, set_id: int):
|
||||
"""
|
||||
Verify if a LEGO set ID is valid.
|
||||
"""
|
||||
if len(set_id) == 5:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def catalog_list(self):
|
||||
"""
|
||||
Fetch the list of LEGO sets from the Brickowl API.
|
||||
"""
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/list/view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Error fetching list, {response.status}")
|
||||
|
||||
@classmethod
|
||||
async def catalog_lookup(self, set_id: int):
|
||||
"""
|
||||
Fetch details from the brickowl API for a specific LEGO set.
|
||||
|
||||
set_id: The LEGO set ID to look up.
|
||||
|
||||
returns JSON response containing set details.
|
||||
"""
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/lookup/{set_id}view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Error fetching set {set_id}, {response.status}")
|
||||
|
||||
@classmethod
|
||||
async def catalog_availability(self, set_id: int):
|
||||
"""
|
||||
Fetch availability information for a specific LEGO set.
|
||||
"""
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/availability/{set_id}view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(
|
||||
f"Error fetching fetching availability for {set_id}, {response.status}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def catalog_id_lookup(self, set_id: int):
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/id_lookup/{set_id}view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"ID lookup failed for set {set_id}, {response.status}")
|
||||
|
||||
@classmethod
|
||||
async def catalog_bulk(self):
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/bulk/view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Error fetching bulk, {response.status}")
|
||||
|
||||
@classmethod
|
||||
async def catalog_search(self, query: str):
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/search/{query}view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Search term {set_id} failed, {response.status}")
|
||||
|
||||
|
||||
# Usage:
|
||||
# obj = await ABrickowlAPI.catalog_lookup(value)
|
||||
import aiohttp
|
||||
import json
|
||||
import dotenv
|
||||
import os
|
||||
import importlib.metadata
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
api_key = os.getenv("BRICKOWL")
|
||||
abrickowl_version = importlib.metadata.version("abrickowlapi")
|
||||
|
||||
|
||||
class ABrickowlAPI:
|
||||
|
||||
def __init__(self, value):
|
||||
self.user_agent = f"abrickowlapi/{abrickowl_version}"
|
||||
self.headers = {"content-type": "application/x-www-form-urlencoded"}
|
||||
self.base_url = "https://api.brickowl.com/v1/"
|
||||
self.session = aiohttp.ClientSession()
|
||||
return self
|
||||
|
||||
def set_valid(self, set_id: int):
|
||||
"""
|
||||
Verify if a LEGO set ID is valid.
|
||||
"""
|
||||
if len(set_id) == 5:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def catalog_list(self):
|
||||
"""
|
||||
Fetch the list of LEGO sets from the Brickowl API.
|
||||
"""
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/list/view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Error fetching list, {response.status}")
|
||||
|
||||
@classmethod
|
||||
async def catalog_lookup(self, set_id: int):
|
||||
"""
|
||||
Fetch details from the brickowl API for a specific LEGO set.
|
||||
|
||||
set_id: The LEGO set ID to look up.
|
||||
|
||||
returns JSON response containing set details.
|
||||
"""
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/lookup/{set_id}view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Error fetching set {set_id}, {response.status}")
|
||||
|
||||
@classmethod
|
||||
async def catalog_availability(self, set_id: int):
|
||||
"""
|
||||
Fetch availability information for a specific LEGO set.
|
||||
"""
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/availability/{set_id}view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(
|
||||
f"Error fetching fetching availability for {set_id}, {response.status}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def catalog_id_lookup(self, set_id: int):
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/id_lookup/{set_id}view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"ID lookup failed for set {set_id}, {response.status}")
|
||||
|
||||
@classmethod
|
||||
async def catalog_bulk(self):
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/bulk/view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Error fetching bulk, {response.status}")
|
||||
|
||||
@classmethod
|
||||
async def catalog_search(self, query: str):
|
||||
async with self.session.get(
|
||||
f"{self.base_url}catalog/search/{query}view?key={api_key}",
|
||||
headers=self.headers,
|
||||
user_agent=self.user_agent,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Search term {set_id} failed, {response.status}")
|
||||
|
||||
|
||||
# Usage:
|
||||
# obj = await ABrickowlAPI.catalog_lookup(value)
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Clean refactored version of abricksetapi.py without debug logging.
|
||||
|
||||
This module provides async functions for interacting with Brickset API
|
||||
to retrieve LEGO set information.
|
||||
"""
|
||||
|
||||
import os
|
||||
import aiohttp
|
||||
import json
|
||||
import dotenv
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from . import throttler
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
class AsyncBricksetAPI:
|
||||
"""Asynchronous client for the Brickset API."""
|
||||
|
||||
def __init__(self, logger=None):
|
||||
"""
|
||||
Initialize Brickset API client.
|
||||
|
||||
Args:
|
||||
logger: Optional logger instance for structured logging
|
||||
"""
|
||||
self.username = os.getenv("BRICKSET_USERNAME")
|
||||
self.password = os.getenv("BRICKSET_PASS")
|
||||
self.api_key = os.getenv("BRICKSET_API_KEY")
|
||||
self.headers = {"UserAgent": "studspider/0.1"}
|
||||
self.throttler = throttler.GlobalThrottler(rate_limit=500, period=60)
|
||||
self.user_hash: Optional[str] = None
|
||||
self.logger = logger
|
||||
|
||||
# API usage tracking (1000 requests per day limit)
|
||||
self.daily_requests_used: Optional[int] = None
|
||||
self.daily_requests_limit = 1000
|
||||
self.usage_last_checked: Optional[datetime] = None
|
||||
self.usage_check_interval = timedelta(hours=1)
|
||||
|
||||
async def login(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Authenticate with Brickset API.
|
||||
|
||||
Returns:
|
||||
dict: Authentication response containing user hash
|
||||
|
||||
Raises:
|
||||
Exception: If authentication fails
|
||||
"""
|
||||
url = "https://brickset.com/api/v3.asmx/login"
|
||||
params = {
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"apiKey": self.api_key,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, params=params, headers=self.headers
|
||||
) as response:
|
||||
if self.logger:
|
||||
self.logger.debug(f"Login response status: {response.status}")
|
||||
|
||||
data = await response.read()
|
||||
result = json.loads(data.decode("utf-8"))
|
||||
self.user_hash = result.get("hash")
|
||||
|
||||
if self.logger:
|
||||
self.logger.info("Successfully authenticated with Brickset API")
|
||||
|
||||
return result
|
||||
|
||||
async def get_key_usage_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch API key usage statistics from Brickset.
|
||||
|
||||
Returns:
|
||||
dict: Usage stats with apiKeyUsage array containing dateStamp and count
|
||||
"""
|
||||
url = "https://brickset.com/api/v3.asmx/getKeyUsageStats"
|
||||
params = {"apiKey": self.api_key}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, params=params, headers=self.headers
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.read()
|
||||
return json.loads(data.decode("utf-8"))
|
||||
else:
|
||||
raise Exception(
|
||||
f"Failed to get usage stats: HTTP {response.status}"
|
||||
)
|
||||
|
||||
async def check_and_update_usage(self) -> bool:
|
||||
"""
|
||||
Check current API usage and update internal counter.
|
||||
Automatically called before each request if needed.
|
||||
|
||||
Returns:
|
||||
bool: True if under limit, False if at/over limit
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
# Check usage if we haven't checked yet or if it's been over an hour
|
||||
should_check = (
|
||||
self.daily_requests_used is None
|
||||
or self.usage_last_checked is None
|
||||
or (now - self.usage_last_checked) > self.usage_check_interval
|
||||
)
|
||||
|
||||
if should_check:
|
||||
try:
|
||||
stats = await self.get_key_usage_stats()
|
||||
usage_array = stats.get("apiKeyUsage", [])
|
||||
if usage_array and len(usage_array) > 0:
|
||||
today_stats = usage_array[0] # Most recent day
|
||||
self.daily_requests_used = today_stats.get("count", 0)
|
||||
self.usage_last_checked = now
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"API usage: {self.daily_requests_used}/{self.daily_requests_limit}"
|
||||
)
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.warning(f"Failed to check API usage: {e}")
|
||||
return True # Don't block requests if usage check fails
|
||||
|
||||
# Check if we're over the limit
|
||||
if self.daily_requests_used is not None:
|
||||
if self.daily_requests_used >= self.daily_requests_limit:
|
||||
if self.logger:
|
||||
self.logger.error(
|
||||
f"Daily API limit reached: {self.daily_requests_used}/{self.daily_requests_limit}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Warn when approaching limit (90%)
|
||||
if self.daily_requests_used >= self.daily_requests_limit * 0.9:
|
||||
if self.logger:
|
||||
self.logger.warning(
|
||||
f"Approaching daily API limit: {self.daily_requests_used}/{self.daily_requests_limit}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def get_set(self, set_number: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch LEGO set information from Brickset API.
|
||||
|
||||
Args:
|
||||
set_number: LEGO set number (e.g., 10338)
|
||||
|
||||
Returns:
|
||||
dict: Set information including:
|
||||
- set_num: LEGO set number
|
||||
- set_name: Official set name
|
||||
- set_theme: Theme (e.g., "Icons")
|
||||
- set_image: Thumbnail URL
|
||||
- set_barcode: UPC or EAN barcode
|
||||
- set_retailprice: US retail price
|
||||
- set_adjustredprice: Retail price + 10% markup
|
||||
- pw_name: Formatted name for Square (e.g., "10338 - ICONS - Bumblebee")
|
||||
- requires_manual_price: True if price >= $110
|
||||
|
||||
Raises:
|
||||
ValueError: If set not found
|
||||
Exception: For API errors or if daily limit exceeded
|
||||
"""
|
||||
# Check API usage before making request
|
||||
if not await self.check_and_update_usage():
|
||||
raise Exception(
|
||||
f"Brickset API daily limit reached ({self.daily_requests_limit} requests/day). "
|
||||
"Please try again tomorrow."
|
||||
)
|
||||
|
||||
if not self.user_hash:
|
||||
await self.login()
|
||||
|
||||
url = "https://brickset.com/api/v3.asmx/getSets"
|
||||
params = {
|
||||
"apiKey": self.api_key,
|
||||
"userHash": self.user_hash,
|
||||
"params": json.dumps({"setNumber": f"{set_number}-1"}),
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, params=params, headers=self.headers
|
||||
) as response:
|
||||
if self.logger:
|
||||
self.logger.debug(f"Get set response status: {response.status}")
|
||||
|
||||
if response.status == 200:
|
||||
data = await response.read()
|
||||
result = json.loads(data.decode("utf-8"))
|
||||
|
||||
if not result.get("sets"):
|
||||
error_msg = f"No data found for set {set_number}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"Successfully retrieved data for set {set_number}"
|
||||
)
|
||||
|
||||
set_data = result["sets"][0]
|
||||
|
||||
# Get UPC barcode, fall back to EAN if not available
|
||||
barcode_data = set_data.get("barcode", {})
|
||||
upc_code = barcode_data.get("UPC") or barcode_data.get("EAN")
|
||||
|
||||
# Extract set information
|
||||
our_set = {
|
||||
"set_num": set_data["number"],
|
||||
"set_name": set_data["name"],
|
||||
"set_theme": set_data["theme"],
|
||||
"set_image": set_data["image"]["thumbnailURL"],
|
||||
"set_barcode": upc_code,
|
||||
"set_retailprice": round(
|
||||
set_data["LEGOCom"]["US"]["retailPrice"]
|
||||
),
|
||||
}
|
||||
|
||||
# Calculate adjusted price (10% markup)
|
||||
retail_price = our_set["set_retailprice"]
|
||||
our_set["set_adjustredprice"] = retail_price + (
|
||||
(10 / 100) * retail_price
|
||||
)
|
||||
|
||||
# Format name for Square inventory
|
||||
our_set["pw_name"] = (
|
||||
f"{our_set['set_num']} - {our_set['set_theme'].upper()} - {our_set['set_name']}"
|
||||
)
|
||||
|
||||
# Flag for manual price review if needed
|
||||
if our_set["set_retailprice"] >= 110:
|
||||
our_set["requires_manual_price"] = True
|
||||
|
||||
return our_set
|
||||
|
||||
elif response.status == 500:
|
||||
error_msg = (
|
||||
"Internal Server Error: Please check API key and parameters."
|
||||
)
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise Exception(error_msg)
|
||||
else:
|
||||
error_msg = f"Unexpected response: {response.status}"
|
||||
if self.logger:
|
||||
self.logger.error(error_msg)
|
||||
raise Exception(error_msg)
|
||||
|
||||
async def throttled_get_set(self, set_number: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get set information with rate limiting (500 requests per 60 seconds).
|
||||
|
||||
Args:
|
||||
set_number: LEGO set number
|
||||
|
||||
Returns:
|
||||
dict: Set information
|
||||
|
||||
Raises:
|
||||
ValueError: If set not found
|
||||
Exception: For API errors
|
||||
"""
|
||||
async with self.throttler:
|
||||
return await self.get_set(set_number)
|
||||
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
This module provides async functions for interacting with Square Catalog API
|
||||
to manage LEGO inventory items.
|
||||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import uuid
|
||||
import re
|
||||
import aiohttp
|
||||
|
||||
from square.core.api_error import ApiError
|
||||
from square import AsyncSquare
|
||||
|
||||
from . import get_sq_env
|
||||
|
||||
|
||||
def get_set_number(text: str) -> int:
|
||||
"""Extract 5-digit LEGO set number from text."""
|
||||
numbers = re.findall(r"\d{5}", text)
|
||||
if not numbers:
|
||||
raise ValueError("No five-digit number found in string")
|
||||
return int(numbers[0])
|
||||
|
||||
|
||||
# Configuration
|
||||
SQUARE_TOKEN = get_sq_env.SQUARE_TOKEN
|
||||
LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID
|
||||
ENVIRONMENT = get_sq_env.environment
|
||||
|
||||
# Initialize Square client
|
||||
client = AsyncSquare(token=SQUARE_TOKEN, environment=ENVIRONMENT)
|
||||
|
||||
|
||||
async def get_itm(sku: str) -> dict:
|
||||
"""
|
||||
Retrieve item details from Square Catalog using a SKU.
|
||||
|
||||
Args:
|
||||
sku: The SKU or text filter to search for
|
||||
|
||||
Returns:
|
||||
dict: Item name and ID
|
||||
|
||||
Raises:
|
||||
IndexError: If no items found
|
||||
Exception: For API errors
|
||||
"""
|
||||
result = await client.catalog.search_items(text_filter=sku, limit=1)
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
name = result_json["items"][0]["item_data"]["name"]
|
||||
itm_id = result_json["items"][0]["id"]
|
||||
return {"name": name, "id": itm_id}
|
||||
|
||||
|
||||
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, version
|
||||
|
||||
Raises:
|
||||
ValueError: If item not found
|
||||
Exception: For API errors
|
||||
"""
|
||||
result = await client.catalog.batch_get(
|
||||
object_ids=[item_id],
|
||||
include_related_objects=False,
|
||||
)
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
|
||||
if not result_json.get("objects"):
|
||||
raise ValueError(f"Item {item_id} not found (may have been deleted)")
|
||||
|
||||
obj = result_json["objects"][0]
|
||||
item_data = obj.get("item_data", {})
|
||||
|
||||
return {
|
||||
"id": obj["id"],
|
||||
"item_data": item_data,
|
||||
"updated_at": obj.get("updated_at"),
|
||||
"version": obj.get("version"),
|
||||
}
|
||||
|
||||
|
||||
async def get_item_by_variation_id(variation_id: str) -> dict:
|
||||
"""
|
||||
Retrieve parent item details from a variation ID.
|
||||
|
||||
Webhooks send variation IDs, but we need the parent item.
|
||||
Searches all items in LEGO category to find the matching variation.
|
||||
|
||||
Args:
|
||||
variation_id: Square catalog item variation ID
|
||||
|
||||
Returns:
|
||||
dict: Parent item details
|
||||
|
||||
Raises:
|
||||
ValueError: If variation/item not found
|
||||
Exception: For API errors
|
||||
"""
|
||||
cursor = None
|
||||
page_count = 0
|
||||
|
||||
while True:
|
||||
page_count += 1
|
||||
|
||||
search_params = {
|
||||
"object_types": ["ITEM"],
|
||||
"query": {
|
||||
"exact_query": {
|
||||
"attribute_name": "category_id",
|
||||
"attribute_value": LEGO_CAT_ID,
|
||||
}
|
||||
},
|
||||
"limit": 1000,
|
||||
}
|
||||
|
||||
if cursor:
|
||||
search_params["cursor"] = cursor
|
||||
|
||||
result = await client.catalog.search(**search_params)
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
|
||||
if not result_json.get("objects"):
|
||||
if page_count == 1:
|
||||
raise ValueError("No items found in LEGO category")
|
||||
break
|
||||
|
||||
# Search items on this page for matching variation
|
||||
for obj in result_json["objects"]:
|
||||
item_data = obj.get("item_data", {})
|
||||
variations = item_data.get("variations", [])
|
||||
|
||||
for variation in variations:
|
||||
if variation.get("id") == variation_id:
|
||||
# Found parent item
|
||||
category_id = item_data.get("category_id") or LEGO_CAT_ID
|
||||
|
||||
return {
|
||||
"id": obj["id"],
|
||||
"name": item_data.get("name", ""),
|
||||
"description": item_data.get("description", ""),
|
||||
"category_id": category_id,
|
||||
"variations": variations,
|
||||
"updated_at": obj.get("updated_at"),
|
||||
"version": obj.get("version"),
|
||||
}
|
||||
|
||||
cursor = result_json.get("cursor")
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
raise ValueError(
|
||||
f"Variation {variation_id} not found in LEGO category (searched {page_count} pages)"
|
||||
)
|
||||
|
||||
|
||||
async def upload_item_image(item_id: str, image_url: str) -> bool:
|
||||
"""
|
||||
Download image from URL and upload to Square catalog item.
|
||||
|
||||
Args:
|
||||
item_id: Square catalog item ID
|
||||
image_url: URL to download image from (e.g., Brickset thumbnail)
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
# Download image
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(image_url) as resp:
|
||||
if resp.status != 200:
|
||||
return False
|
||||
image_data = await resp.read()
|
||||
|
||||
# Determine content type
|
||||
content_type = "image/jpeg"
|
||||
if image_url.lower().endswith(".png"):
|
||||
content_type = "image/png"
|
||||
elif image_url.lower().endswith(".gif"):
|
||||
content_type = "image/gif"
|
||||
|
||||
# Upload to Square
|
||||
result = await client.catalog.images.create(
|
||||
image_file=image_data,
|
||||
request={
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
"image": {
|
||||
"type": "IMAGE",
|
||||
"id": f"#IMAGE_{item_id}",
|
||||
"image_data": {"name": "LEGO Set Image"},
|
||||
},
|
||||
"object_id": item_id,
|
||||
},
|
||||
image_file_name=f"lego_{item_id}.jpg",
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
return result_json.get("image") is not None
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def update_itm(item_id: str, lego_set: dict, item_data: dict = None) -> bool:
|
||||
"""
|
||||
Update Square catalog item with Brickset LEGO set data.
|
||||
|
||||
Updates name, description, price, SKU, UPC, and category while preserving
|
||||
all other item fields (location settings, tax, etc).
|
||||
|
||||
Args:
|
||||
item_id: Square catalog item ID to update
|
||||
lego_set: Dict containing LEGO set details from Brickset:
|
||||
- set_num: LEGO set number (e.g., "75192")
|
||||
- set_name: Official LEGO set name
|
||||
- set_theme: LEGO theme (e.g., "Star Wars")
|
||||
- set_barcode: UPC barcode
|
||||
- set_image: Thumbnail URL
|
||||
- pw_name: Formatted name (e.g., "75192 - STAR WARS - Millennium Falcon")
|
||||
- set_adjustredprice: Retail price with 10% markup
|
||||
item_data: Optional pre-fetched item data with category_id
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
|
||||
Raises:
|
||||
KeyError: If expected keys missing from lego_set
|
||||
Exception: For API errors
|
||||
"""
|
||||
try:
|
||||
# Extract LEGO set data
|
||||
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"]
|
||||
|
||||
price_in_cents = int(set_adjustedprice * 100)
|
||||
|
||||
# Retry logic for version conflicts
|
||||
max_retries = 3
|
||||
retry_delay = 0.5
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Fetch fresh item data for latest version
|
||||
fresh_item = await get_item_by_id(item_id)
|
||||
except ValueError:
|
||||
# Item was deleted
|
||||
return False
|
||||
|
||||
variations = fresh_item.get("item_data", {}).get("variations", [])
|
||||
current_version = fresh_item.get("version")
|
||||
|
||||
# Get variation details
|
||||
if variations:
|
||||
variation_id = variations[0]["id"]
|
||||
variation_version = variations[0].get("version")
|
||||
else:
|
||||
variation_id = None
|
||||
variation_version = None
|
||||
|
||||
# Determine category ID
|
||||
# batch_get doesn't return category_id, so use from item_data or fallback
|
||||
if item_data and item_data.get("category_id"):
|
||||
category_id = item_data.get("category_id")
|
||||
else:
|
||||
category_id = LEGO_CAT_ID
|
||||
|
||||
# Preserve all existing item_data and update only what we need
|
||||
existing_item_data = fresh_item.get("item_data", {})
|
||||
existing_item_data["name"] = pw_name
|
||||
existing_item_data["description"] = f"{set_name} - {set_theme}"
|
||||
existing_item_data["category_id"] = category_id
|
||||
|
||||
# Update only the specific variation
|
||||
existing_variations = existing_item_data.get("variations", [])
|
||||
updated_variations = []
|
||||
variation_found = False
|
||||
|
||||
for existing_var in existing_variations:
|
||||
if existing_var.get("id") == variation_id:
|
||||
# Preserve all variation fields, update only specific ones
|
||||
updated_var = existing_var.copy()
|
||||
updated_var_data = existing_var.get(
|
||||
"item_variation_data", {}
|
||||
).copy()
|
||||
|
||||
updated_var_data["price_money"] = {
|
||||
"amount": price_in_cents,
|
||||
"currency": "USD",
|
||||
}
|
||||
updated_var_data["sku"] = set_number
|
||||
updated_var_data["upc"] = set_upc if set_upc else None
|
||||
|
||||
updated_var["item_variation_data"] = updated_var_data
|
||||
|
||||
if variation_version is not None:
|
||||
updated_var["version"] = variation_version
|
||||
|
||||
updated_variations.append(updated_var)
|
||||
variation_found = True
|
||||
else:
|
||||
updated_variations.append(existing_var)
|
||||
|
||||
# Create new variation if not found
|
||||
if not variation_found:
|
||||
new_variation = {
|
||||
"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_number,
|
||||
"upc": set_upc if set_upc else None,
|
||||
},
|
||||
}
|
||||
if variation_version is not None:
|
||||
new_variation["version"] = variation_version
|
||||
updated_variations.append(new_variation)
|
||||
|
||||
existing_item_data["variations"] = updated_variations
|
||||
|
||||
# Build upsert request
|
||||
upsert_body = {
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
"object": {
|
||||
"type": "ITEM",
|
||||
"id": item_id,
|
||||
"version": current_version,
|
||||
"item_data": existing_item_data,
|
||||
},
|
||||
}
|
||||
|
||||
# Perform upsert
|
||||
try:
|
||||
result = await client.catalog.object.upsert(
|
||||
idempotency_key=upsert_body["idempotency_key"],
|
||||
object=upsert_body["object"],
|
||||
)
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
|
||||
if result_json.get("catalog_object"):
|
||||
# Upload image if available (currently disabled)
|
||||
# if set_image:
|
||||
# await upload_item_image(item_id, set_image)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
except ApiError as e:
|
||||
# Handle version conflicts with retry
|
||||
if e.status_code == 400 and e.body and isinstance(e.body, dict):
|
||||
errors = e.body.get("errors", [])
|
||||
if any(err.get("code") == "VERSION_MISMATCH" for err in errors):
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
continue
|
||||
else:
|
||||
return False
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def acat2id(cat_id: str) -> list[dict]:
|
||||
"""
|
||||
List all items in a category.
|
||||
|
||||
Args:
|
||||
cat_id: Category ID to search
|
||||
|
||||
Returns:
|
||||
list[dict]: List of items with id, name, set number, updated_at, version
|
||||
"""
|
||||
result = await client.catalog.search(
|
||||
object_types=["ITEM"],
|
||||
query={
|
||||
"exact_query": {
|
||||
"attribute_name": "category_id",
|
||||
"attribute_value": cat_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
items = []
|
||||
|
||||
for obj in result_json.get("objects", []):
|
||||
item_name = obj.get("item_data", {}).get("name", "")
|
||||
set_number = None
|
||||
|
||||
if re.findall(r"\d{5}", item_name):
|
||||
try:
|
||||
set_number = get_set_number(item_name)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
items.append(
|
||||
{
|
||||
"id": obj["id"],
|
||||
"name": item_name or "Unknown",
|
||||
"number": set_number,
|
||||
"updated_at": obj.get("updated_at"),
|
||||
"version": obj.get("version"),
|
||||
}
|
||||
)
|
||||
|
||||
return items
|
||||
@@ -4,8 +4,8 @@ List all Square categories with their IDs to help identify the correct LEGO cate
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from . import asquare
|
||||
from . import get_sq_env
|
||||
from .. import asquare
|
||||
from .. import get_sq_env
|
||||
|
||||
|
||||
async def list_categories():
|
||||
@@ -17,47 +17,53 @@ from pathlib import Path
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from asquare import AsyncSquare, get_square_client
|
||||
from get_sq_env import SQUARE_LEGO_CAT_ID
|
||||
import get_sq_env
|
||||
from square import AsyncSquare
|
||||
|
||||
SQUARE_LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID
|
||||
|
||||
|
||||
async def migrate_sku_to_gtin():
|
||||
"""Migrate SKU values to GTIN for all LEGO items."""
|
||||
client = await get_square_client()
|
||||
asquare = AsyncSquare()
|
||||
|
||||
client = AsyncSquare(
|
||||
token=get_sq_env.SQUARE_TOKEN,
|
||||
environment=get_sq_env.environment,
|
||||
)
|
||||
|
||||
print(f"[@INFO] Searching for items in LEGO category {SQUARE_LEGO_CAT_ID}...")
|
||||
|
||||
|
||||
# Search all items in LEGO category
|
||||
cursor = None
|
||||
page = 1
|
||||
total_items = 0
|
||||
updated_items = 0
|
||||
|
||||
|
||||
while True:
|
||||
print(f"[@INFO] Processing page {page}...")
|
||||
|
||||
result = await client.catalog.search(
|
||||
body={
|
||||
"object_types": ["ITEM"],
|
||||
"query": {
|
||||
"exact_query": {
|
||||
"attribute_name": "category_id",
|
||||
"attribute_value": SQUARE_LEGO_CAT_ID,
|
||||
}
|
||||
},
|
||||
"limit": 100,
|
||||
"cursor": cursor,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
search_params = {
|
||||
"object_types": ["ITEM"],
|
||||
"query": {
|
||||
"exact_query": {
|
||||
"attribute_name": "category_id",
|
||||
"attribute_value": SQUARE_LEGO_CAT_ID,
|
||||
}
|
||||
},
|
||||
"limit": 100,
|
||||
}
|
||||
|
||||
if cursor:
|
||||
search_params["cursor"] = cursor
|
||||
|
||||
result = await client.catalog.search(**search_params)
|
||||
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
items = result_json.get("objects", [])
|
||||
cursor = result_json.get("cursor")
|
||||
|
||||
|
||||
if not items:
|
||||
break
|
||||
|
||||
|
||||
# Process each item
|
||||
for item in items:
|
||||
item_id = item["id"]
|
||||
@@ -65,17 +71,17 @@ async def migrate_sku_to_gtin():
|
||||
item_name = item_data.get("name", "")
|
||||
variations = item_data.get("variations", [])
|
||||
item_version = item.get("version")
|
||||
|
||||
|
||||
total_items += 1
|
||||
needs_update = False
|
||||
updated_variations = []
|
||||
|
||||
|
||||
# Extract set number from item name if it matches LEGO format
|
||||
set_number = None
|
||||
match = re.search(r"^(\d{5})\s*-", item_name)
|
||||
if match:
|
||||
set_number = match.group(1)
|
||||
|
||||
|
||||
# Check each variation
|
||||
for var in variations:
|
||||
var_data = var.get("item_variation_data", {})
|
||||
@@ -83,36 +89,29 @@ async def migrate_sku_to_gtin():
|
||||
var_version = var.get("version")
|
||||
sku = var_data.get("sku")
|
||||
upc = var_data.get("upc")
|
||||
|
||||
|
||||
# If SKU exists but UPC doesn't, migrate
|
||||
if sku and not upc:
|
||||
print(f"[@INFO] Item {item_id} ({item_name})")
|
||||
print(f"[@INFO] Variation {var_id}: Moving SKU '{sku}' to UPC")
|
||||
|
||||
# Build updated variation
|
||||
updated_var = {
|
||||
"type": "ITEM_VARIATION",
|
||||
"id": var_id,
|
||||
"version": var_version,
|
||||
"item_variation_data": {
|
||||
"item_id": item_id,
|
||||
"name": var_data.get("name", "Regular"),
|
||||
"pricing_type": var_data.get("pricing_type", "FIXED_PRICING"),
|
||||
"upc": sku, # Move SKU value to UPC
|
||||
"sku": set_number if set_number else None, # Set to LEGO set number if available
|
||||
}
|
||||
}
|
||||
|
||||
# Preserve pricing info
|
||||
if "price_money" in var_data:
|
||||
updated_var["item_variation_data"]["price_money"] = var_data["price_money"]
|
||||
|
||||
|
||||
# Preserve all existing variation data and only modify what we need
|
||||
updated_var = var.copy() # Shallow copy the entire variation
|
||||
updated_var_data = var_data.copy() # Copy the variation_data
|
||||
|
||||
# Update only UPC and SKU fields
|
||||
updated_var_data["upc"] = sku # Move SKU value to UPC
|
||||
updated_var_data["sku"] = (
|
||||
set_number if set_number else None
|
||||
) # Set to LEGO set number
|
||||
|
||||
updated_var["item_variation_data"] = updated_var_data
|
||||
updated_variations.append(updated_var)
|
||||
needs_update = True
|
||||
else:
|
||||
# Keep variation as-is
|
||||
updated_variations.append(var)
|
||||
|
||||
|
||||
# Update item if needed
|
||||
if needs_update:
|
||||
try:
|
||||
@@ -125,36 +124,34 @@ async def migrate_sku_to_gtin():
|
||||
fresh_item = fresh_json["objects"][0]
|
||||
fresh_item_data = fresh_item.get("item_data", {})
|
||||
fresh_version = fresh_item.get("version")
|
||||
|
||||
|
||||
# Update variations while preserving all other fields
|
||||
fresh_item_data["variations"] = updated_variations
|
||||
|
||||
upsert_body = {
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
"object": {
|
||||
|
||||
await client.catalog.object.upsert(
|
||||
idempotency_key=str(uuid.uuid4()),
|
||||
object={
|
||||
"type": "ITEM",
|
||||
"id": item_id,
|
||||
"version": fresh_version,
|
||||
"item_data": fresh_item_data,
|
||||
}
|
||||
}
|
||||
|
||||
await client.catalog.object.upsert(body=upsert_body)
|
||||
},
|
||||
)
|
||||
print(f"[@INFO] ✓ Successfully updated item {item_id}")
|
||||
updated_items += 1
|
||||
|
||||
|
||||
# Small delay to avoid rate limits
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"[@ERROR] Failed to update item {item_id}: {e}")
|
||||
|
||||
|
||||
page += 1
|
||||
|
||||
|
||||
# If no cursor, we're done
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
|
||||
print(f"\n[@INFO] Migration complete!")
|
||||
print(f"[@INFO] Total items processed: {total_items}")
|
||||
print(f"[@INFO] Items updated: {updated_items}")
|
||||
|
||||
@@ -1,65 +1,70 @@
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
import dotenv
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
USERNAME = os.getenv("BRICKSET_USERNAME")
|
||||
PASS = os.getenv("BRICKSET_PASS")
|
||||
BRICKSET_API_KEY = os.getenv("BRICKSET_API_KEY")
|
||||
|
||||
debug = False # Set to True to enable debug output
|
||||
|
||||
|
||||
def login(username, password):
|
||||
url = "https://brickset.com/api/v3.asmx/login"
|
||||
headers = {"UserAgent": "studspider/0.1"}
|
||||
params = {
|
||||
"UserAgent": "studspider/0.1",
|
||||
"username": username,
|
||||
"password": password,
|
||||
"apiKey": BRICKSET_API_KEY,
|
||||
}
|
||||
response = requests.get(url, params=params, headers=headers)
|
||||
if debug:
|
||||
print(f"[@DEBUG] Login response: {response.status_code} - {response.text}\n")
|
||||
return response.json()
|
||||
|
||||
|
||||
def get_set(set_number: int) -> dict:
|
||||
login_response = login(USERNAME, PASS)
|
||||
url = f"https://brickset.com/api/v3.asmx/getSets?apiKey={BRICKSET_API_KEY}&userHash={login_response["hash"]}¶ms={{'setNumber' : '{set_number}-1'}}"
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if not data["sets"] and data["message"] == "API limit exceeded":
|
||||
raise Exception("API limit exceeded. Please try again later.")
|
||||
return False
|
||||
|
||||
print(f"[@DEBUG]\t{data}")
|
||||
our_set = {
|
||||
"set_num": data["sets"][0]["number"],
|
||||
"set_name": data["sets"][0]["name"],
|
||||
"set_theme": data["sets"][0]["theme"],
|
||||
"set_image": data["sets"][0]["image"]["thumbnailURL"],
|
||||
"set_barcode": data["sets"][0]["barcode"]["UPC"],
|
||||
"set_retailprice": round(data["sets"][0]["LEGOCom"]["US"]["retailPrice"]),
|
||||
}
|
||||
# add PW 10% b4 tax
|
||||
our_set.update(
|
||||
{"set_adjustredprice": our_set + ((10 / 100) * our_set["set_retailprice"])}
|
||||
)
|
||||
our_set["pw_name"] = (
|
||||
f"{our_set['set_num']} - {our_set["set_theme"].upper()} {our_set['set_name']}"
|
||||
)
|
||||
if our_set["set_retailprice"] >= 110:
|
||||
our_set["requires_manual_price"] = True
|
||||
return our_set
|
||||
|
||||
elif response.status_code == 500:
|
||||
raise Exception(
|
||||
"Internal Server Error: Please check the API key and parameters."
|
||||
)
|
||||
else:
|
||||
raise Exception("Unexpected response from the API.")
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
import dotenv
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
USERNAME = os.getenv("BRICKSET_USERNAME")
|
||||
PASS = os.getenv("BRICKSET_PASS")
|
||||
BRICKSET_API_KEY = os.getenv("BRICKSET_API_KEY")
|
||||
|
||||
debug = False # Set to True to enable debug output
|
||||
|
||||
|
||||
def login(username, password):
|
||||
url = "https://brickset.com/api/v3.asmx/login"
|
||||
headers = {"UserAgent": "studspider/0.1"}
|
||||
params = {
|
||||
"UserAgent": "studspider/0.1",
|
||||
"username": username,
|
||||
"password": password,
|
||||
"apiKey": BRICKSET_API_KEY,
|
||||
}
|
||||
response = requests.get(url, params=params, headers=headers)
|
||||
if debug:
|
||||
print(f"[@DEBUG] Login response: {response.status_code} - {response.text}\n")
|
||||
return response.json()
|
||||
|
||||
|
||||
def get_set(set_number: int) -> dict:
|
||||
login_response = login(USERNAME, PASS)
|
||||
url = f"https://brickset.com/api/v3.asmx/getSets?apiKey={BRICKSET_API_KEY}&userHash={login_response["hash"]}¶ms={{'setNumber' : '{set_number}-1'}}"
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if not data["sets"] and data["message"] == "API limit exceeded":
|
||||
raise Exception("API limit exceeded. Please try again later.")
|
||||
return False
|
||||
|
||||
print(f"[@DEBUG]\t{data}")
|
||||
|
||||
# Try to get UPC barcode, fall back to EAN if not available
|
||||
barcode_data = data["sets"][0].get("barcode", {})
|
||||
upc_code = barcode_data.get("UPC") or barcode_data.get("EAN")
|
||||
|
||||
our_set = {
|
||||
"set_num": data["sets"][0]["number"],
|
||||
"set_name": data["sets"][0]["name"],
|
||||
"set_theme": data["sets"][0]["theme"],
|
||||
"set_image": data["sets"][0]["image"]["thumbnailURL"],
|
||||
"set_barcode": upc_code,
|
||||
"set_retailprice": round(data["sets"][0]["LEGOCom"]["US"]["retailPrice"]),
|
||||
}
|
||||
# add PW 10% b4 tax
|
||||
our_set.update(
|
||||
{"set_adjustredprice": our_set + ((10 / 100) * our_set["set_retailprice"])}
|
||||
)
|
||||
our_set["pw_name"] = (
|
||||
f"{our_set['set_num']} - {our_set["set_theme"].upper()} - {our_set['set_name']}"
|
||||
)
|
||||
if our_set["set_retailprice"] >= 110:
|
||||
our_set["requires_manual_price"] = True
|
||||
return our_set
|
||||
|
||||
elif response.status_code == 500:
|
||||
raise Exception(
|
||||
"Internal Server Error: Please check the API key and parameters."
|
||||
)
|
||||
else:
|
||||
raise Exception("Unexpected response from the API.")
|
||||
@@ -1,66 +1,66 @@
|
||||
import json
|
||||
|
||||
import get_sq_env
|
||||
|
||||
SQUARE_TOKEN = get_sq_env.SQUARE_TOKEN
|
||||
LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID
|
||||
ENVIRONMENT = get_sq_env.environment
|
||||
|
||||
from square import Square
|
||||
from square.environment import SquareEnvironment
|
||||
|
||||
|
||||
def get_square_client():
|
||||
"""Initialize and return a Square client."""
|
||||
if not SQUARE_TOKEN:
|
||||
raise ValueError("SQUARE_TOKEN environment variable is not set.")
|
||||
square = Square(token=SQUARE_TOKEN, environment=ENVIRONMENT) # type: ignore
|
||||
return square
|
||||
|
||||
|
||||
client = get_square_client()
|
||||
|
||||
|
||||
def get_itm(item_id: str):
|
||||
"""Fetch an item from Square by its ID."""
|
||||
result = client.catalog.search_items(text_filter=item_id, limit=1)
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
ids = {
|
||||
"item_id": result_json["items"][0]["id"],
|
||||
"vari_id": result_json["items"][0]["item_data"]["variations"][0][
|
||||
"item_variation_data"
|
||||
]["item_id"],
|
||||
}
|
||||
item = {
|
||||
"set_name": result_json["items"][0]["item_data"]["name"],
|
||||
"set_price": result_json["items"][0]["item_data"]["variations"][0][
|
||||
"item_variation_data"
|
||||
]["price_money"],
|
||||
"set_barcode": result_json["items"][0]["item_data"]["variations"][0][
|
||||
"item_variation_data"
|
||||
]["sku"],
|
||||
}
|
||||
return [ids, item]
|
||||
|
||||
|
||||
def cat2id(cat_id: str):
|
||||
"""Return a list of item IDs within the specified category ID."""
|
||||
result = client.catalog.search(
|
||||
object_types=["ITEM"],
|
||||
query={
|
||||
"exact_query": {
|
||||
"attribute_name": "category_id",
|
||||
"attribute_value": cat_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
item_ids = [obj["id"] for obj in result_json.get("objects", [])]
|
||||
return item_ids
|
||||
|
||||
|
||||
def test() -> None:
|
||||
# all_lego_sets = cat2id(SQUARE_LEGO_CAT_ID) # type: ignore
|
||||
# result = get_itm("71433")
|
||||
# print(result)
|
||||
pass
|
||||
import json
|
||||
|
||||
import get_sq_env
|
||||
|
||||
SQUARE_TOKEN = get_sq_env.SQUARE_TOKEN
|
||||
LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID
|
||||
ENVIRONMENT = get_sq_env.environment
|
||||
|
||||
from square import Square
|
||||
from square.environment import SquareEnvironment
|
||||
|
||||
|
||||
def get_square_client():
|
||||
"""Initialize and return a Square client."""
|
||||
if not SQUARE_TOKEN:
|
||||
raise ValueError("SQUARE_TOKEN environment variable is not set.")
|
||||
square = Square(token=SQUARE_TOKEN, environment=ENVIRONMENT) # type: ignore
|
||||
return square
|
||||
|
||||
|
||||
client = get_square_client()
|
||||
|
||||
|
||||
def get_itm(item_id: str):
|
||||
"""Fetch an item from Square by its ID."""
|
||||
result = client.catalog.search_items(text_filter=item_id, limit=1)
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
ids = {
|
||||
"item_id": result_json["items"][0]["id"],
|
||||
"vari_id": result_json["items"][0]["item_data"]["variations"][0][
|
||||
"item_variation_data"
|
||||
]["item_id"],
|
||||
}
|
||||
item = {
|
||||
"set_name": result_json["items"][0]["item_data"]["name"],
|
||||
"set_price": result_json["items"][0]["item_data"]["variations"][0][
|
||||
"item_variation_data"
|
||||
]["price_money"],
|
||||
"set_barcode": result_json["items"][0]["item_data"]["variations"][0][
|
||||
"item_variation_data"
|
||||
]["sku"],
|
||||
}
|
||||
return [ids, item]
|
||||
|
||||
|
||||
def cat2id(cat_id: str):
|
||||
"""Return a list of item IDs within the specified category ID."""
|
||||
result = client.catalog.search(
|
||||
object_types=["ITEM"],
|
||||
query={
|
||||
"exact_query": {
|
||||
"attribute_name": "category_id",
|
||||
"attribute_value": cat_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
item_ids = [obj["id"] for obj in result_json.get("objects", [])]
|
||||
return item_ids
|
||||
|
||||
|
||||
def test() -> None:
|
||||
# all_lego_sets = cat2id(SQUARE_LEGO_CAT_ID) # type: ignore
|
||||
# result = get_itm("71433")
|
||||
# print(result)
|
||||
pass
|
||||
+116
-55
@@ -4,15 +4,24 @@ Queries Square catalog to collect metrics about LEGO inventory quality.
|
||||
"""
|
||||
|
||||
import json
|
||||
import git
|
||||
from typing import Dict, Any
|
||||
from square import AsyncSquare
|
||||
import get_sq_env
|
||||
from . import get_sq_env
|
||||
from . import abricksetapi
|
||||
|
||||
|
||||
def get_git_commit() -> str:
|
||||
"""Get first 6 characters of current git commit hash"""
|
||||
try:
|
||||
repo = git.Repo(search_parent_directories=True)
|
||||
return repo.head.commit.hexsha[:6]
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# Initialize Square client
|
||||
client = AsyncSquare(
|
||||
token=get_sq_env.SQUARE_TOKEN,
|
||||
environment=get_sq_env.environment
|
||||
)
|
||||
client = AsyncSquare(token=get_sq_env.SQUARE_TOKEN, environment=get_sq_env.environment)
|
||||
|
||||
LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID
|
||||
|
||||
@@ -20,7 +29,7 @@ LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID
|
||||
async def get_daily_stats() -> Dict[str, Any]:
|
||||
"""
|
||||
Gather all metrics for daily_stats table in a single pass.
|
||||
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"total_items": int,
|
||||
@@ -40,38 +49,38 @@ async def get_daily_stats() -> Dict[str, Any]:
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
objects = result_json.get("objects", [])
|
||||
|
||||
|
||||
# Initialize counters
|
||||
total_items = 0
|
||||
enriched_items = 0
|
||||
missing_upcs = 0
|
||||
missing_images = 0
|
||||
total_catalog_value = 0.0
|
||||
|
||||
|
||||
for obj in objects:
|
||||
total_items += 1
|
||||
item_data = obj.get("item_data", {})
|
||||
|
||||
|
||||
# Check if item has all enrichment data
|
||||
has_upc = False
|
||||
has_image = False
|
||||
has_proper_name = False
|
||||
item_price = 0.0
|
||||
|
||||
|
||||
# Check variations for UPC and price
|
||||
variations = item_data.get("variations", [])
|
||||
if variations:
|
||||
var_data = variations[0].get("item_variation_data", {})
|
||||
|
||||
|
||||
# Check UPC
|
||||
if var_data.get("upc"):
|
||||
has_upc = True
|
||||
else:
|
||||
missing_upcs += 1
|
||||
|
||||
|
||||
# Get price (stored in cents)
|
||||
price_money = var_data.get("price_money", {})
|
||||
if price_money and price_money.get("amount"):
|
||||
@@ -79,22 +88,22 @@ async def get_daily_stats() -> Dict[str, Any]:
|
||||
total_catalog_value += item_price
|
||||
else:
|
||||
missing_upcs += 1
|
||||
|
||||
|
||||
# Check for image
|
||||
if item_data.get("image_ids"):
|
||||
has_image = True
|
||||
else:
|
||||
missing_images += 1
|
||||
|
||||
|
||||
# Check if name follows format: "12345 - THEME Set Name"
|
||||
name = item_data.get("name", "")
|
||||
if " - " in name and name.split()[0].isdigit() and len(name.split()[0]) == 5:
|
||||
has_proper_name = True
|
||||
|
||||
|
||||
# Item is "enriched" if it has UPC, image, and proper name format
|
||||
if has_upc and has_image and has_proper_name:
|
||||
enriched_items += 1
|
||||
|
||||
|
||||
return {
|
||||
"total_items": total_items,
|
||||
"enriched_items": enriched_items,
|
||||
@@ -104,12 +113,62 @@ async def get_daily_stats() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def get_total_items() -> int:
|
||||
async def get_brickset_cache_stats() -> Dict[str, Any]:
|
||||
"""
|
||||
Count total items in LEGO category.
|
||||
|
||||
Get Brickset API cache statistics.
|
||||
|
||||
Returns:
|
||||
int: Total number of items
|
||||
dict: {
|
||||
"cache_enabled": bool,
|
||||
"cache_hits": int,
|
||||
"cache_misses": int,
|
||||
"cache_size": int,
|
||||
"cache_maxsize": int,
|
||||
"hit_rate": float (0-100%)
|
||||
}
|
||||
"""
|
||||
# Create a temporary client instance to access cache info
|
||||
temp_client = abricksetapi.AsyncBricksetAPI()
|
||||
|
||||
# Check if get_set has cache_info (it should with @alru_cache)
|
||||
if hasattr(temp_client.get_set, "cache_info"):
|
||||
info = await temp_client.get_set.cache_info()
|
||||
hits = info.hits
|
||||
misses = info.misses
|
||||
total = hits + misses
|
||||
hit_rate = (hits / total * 100) if total > 0 else 0.0
|
||||
|
||||
return {
|
||||
"cache_enabled": True,
|
||||
"cache_hits": hits,
|
||||
"cache_misses": misses,
|
||||
"cache_size": info.currsize,
|
||||
"cache_maxsize": info.maxsize,
|
||||
"hit_rate": round(hit_rate, 2),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"cache_enabled": False,
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"cache_size": 0,
|
||||
"cache_maxsize": 0,
|
||||
"hit_rate": 0.0,
|
||||
}
|
||||
|
||||
|
||||
async def get_daily_stats() -> Dict[str, Any]:
|
||||
"""
|
||||
Gather all metrics for daily_stats table in a single pass.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"total_items": int,
|
||||
"enriched_items": int,
|
||||
"missing_upcs": int,
|
||||
"missing_images": int,
|
||||
"total_catalog_value": float
|
||||
}
|
||||
"""
|
||||
result = await client.catalog.search(
|
||||
object_types=["ITEM"],
|
||||
@@ -120,7 +179,7 @@ async def get_total_items() -> int:
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
return len(result_json.get("objects", []))
|
||||
|
||||
@@ -128,7 +187,7 @@ async def get_total_items() -> int:
|
||||
async def get_enriched_count() -> int:
|
||||
"""
|
||||
Count items with complete enrichment (UPC + image + proper name format).
|
||||
|
||||
|
||||
Returns:
|
||||
int: Number of fully enriched items
|
||||
"""
|
||||
@@ -141,25 +200,25 @@ async def get_enriched_count() -> int:
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
objects = result_json.get("objects", [])
|
||||
|
||||
|
||||
enriched_count = 0
|
||||
|
||||
|
||||
for obj in objects:
|
||||
item_data = obj.get("item_data", {})
|
||||
|
||||
|
||||
# Check UPC
|
||||
has_upc = False
|
||||
variations = item_data.get("variations", [])
|
||||
if variations:
|
||||
var_data = variations[0].get("item_variation_data", {})
|
||||
has_upc = bool(var_data.get("upc"))
|
||||
|
||||
|
||||
# Check image
|
||||
has_image = bool(item_data.get("image_ids"))
|
||||
|
||||
|
||||
# Check name format: "12345 - THEME Set Name"
|
||||
name = item_data.get("name", "")
|
||||
has_proper_name = False
|
||||
@@ -167,17 +226,17 @@ async def get_enriched_count() -> int:
|
||||
first_part = name.split()[0]
|
||||
if first_part.isdigit() and len(first_part) == 5:
|
||||
has_proper_name = True
|
||||
|
||||
|
||||
if has_upc and has_image and has_proper_name:
|
||||
enriched_count += 1
|
||||
|
||||
|
||||
return enriched_count
|
||||
|
||||
|
||||
async def get_missing_upcs() -> int:
|
||||
"""
|
||||
Count items without UPC barcodes.
|
||||
|
||||
|
||||
Returns:
|
||||
int: Number of items missing UPCs
|
||||
"""
|
||||
@@ -190,31 +249,31 @@ async def get_missing_upcs() -> int:
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
objects = result_json.get("objects", [])
|
||||
|
||||
|
||||
missing_count = 0
|
||||
|
||||
|
||||
for obj in objects:
|
||||
item_data = obj.get("item_data", {})
|
||||
variations = item_data.get("variations", [])
|
||||
|
||||
|
||||
if not variations:
|
||||
missing_count += 1
|
||||
continue
|
||||
|
||||
|
||||
var_data = variations[0].get("item_variation_data", {})
|
||||
if not var_data.get("upc"):
|
||||
missing_count += 1
|
||||
|
||||
|
||||
return missing_count
|
||||
|
||||
|
||||
async def get_missing_images() -> int:
|
||||
"""
|
||||
Count items without images.
|
||||
|
||||
|
||||
Returns:
|
||||
int: Number of items missing images
|
||||
"""
|
||||
@@ -227,24 +286,24 @@ async def get_missing_images() -> int:
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
objects = result_json.get("objects", [])
|
||||
|
||||
|
||||
missing_count = 0
|
||||
|
||||
|
||||
for obj in objects:
|
||||
item_data = obj.get("item_data", {})
|
||||
if not item_data.get("image_ids"):
|
||||
missing_count += 1
|
||||
|
||||
|
||||
return missing_count
|
||||
|
||||
|
||||
async def get_total_catalog_value() -> float:
|
||||
"""
|
||||
Calculate total dollar value of all items in LEGO category.
|
||||
|
||||
|
||||
Returns:
|
||||
float: Total catalog value in dollars
|
||||
"""
|
||||
@@ -257,42 +316,44 @@ async def get_total_catalog_value() -> float:
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
result_json = json.loads(result.model_dump_json())
|
||||
objects = result_json.get("objects", [])
|
||||
|
||||
|
||||
total_value = 0.0
|
||||
|
||||
|
||||
for obj in objects:
|
||||
item_data = obj.get("item_data", {})
|
||||
variations = item_data.get("variations", [])
|
||||
|
||||
|
||||
if variations:
|
||||
var_data = variations[0].get("item_variation_data", {})
|
||||
price_money = var_data.get("price_money", {})
|
||||
|
||||
|
||||
if price_money and price_money.get("amount"):
|
||||
# Price is stored in cents, convert to dollars
|
||||
total_value += price_money["amount"] / 100.0
|
||||
|
||||
|
||||
return round(total_value, 2)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
|
||||
async def main():
|
||||
print("Gathering daily statistics for LEGO catalog...")
|
||||
|
||||
|
||||
stats = await get_daily_stats()
|
||||
|
||||
|
||||
print(f"\n=== Daily Stats ===")
|
||||
print(f"Total Items: {stats['total_items']}")
|
||||
print(f"Enriched Items: {stats['enriched_items']}")
|
||||
print(f"Missing UPCs: {stats['missing_upcs']}")
|
||||
print(f"Missing Images: {stats['missing_images']}")
|
||||
print(f"Total Catalog Value: ${stats['total_catalog_value']:,.2f}")
|
||||
print(f"Completion Rate: {(stats['enriched_items'] / stats['total_items'] * 100):.1f}%")
|
||||
|
||||
print(
|
||||
f"Completion Rate: {(stats['enriched_items'] / stats['total_items'] * 100):.1f}%"
|
||||
)
|
||||
|
||||
asyncio.run(main())
|
||||
+40
-13
@@ -5,6 +5,7 @@ from typing import Dict, Any
|
||||
from fastapi import FastAPI, Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
import src.get_sq_env as sq_env
|
||||
import src.stats as stats
|
||||
|
||||
# Square webhook validation
|
||||
from square.utils.webhooks_helper import verify_signature
|
||||
@@ -14,18 +15,6 @@ 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:
|
||||
print(f"[CONFIG] Signature key loaded: {SQUARE_WEBHOOK_SIGNATURE_KEY[:10]}...")
|
||||
else:
|
||||
print("[CONFIG] WARNING: SQUARE_WEBHOOK_SIGNATURE_KEY not set!")
|
||||
|
||||
if SQUARE_WEBHOOK_URL:
|
||||
print(f"[CONFIG] Webhook URL: {SQUARE_WEBHOOK_URL}")
|
||||
else:
|
||||
print("[CONFIG] WARNING: SQUARE_WEBHOOK_URL not set!")
|
||||
|
||||
# In-memory store for processed event IDs (idempotency)
|
||||
processed_event_ids = set()
|
||||
MAX_STORED_EVENT_IDS = 10000
|
||||
@@ -40,7 +29,11 @@ async def root():
|
||||
return {
|
||||
"service": "Studspider",
|
||||
"status": "running",
|
||||
"endpoints": {"webhook": "/hook (POST)", "health": "/health (GET)"},
|
||||
"endpoints": {
|
||||
"webhook": "/hook (POST)",
|
||||
"health": "/health (GET)",
|
||||
"stats": "/stats (GET)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +48,40 @@ async def health_check():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/stats")
|
||||
async def get_stats():
|
||||
"""
|
||||
Get daily statistics for LEGO inventory.
|
||||
|
||||
Returns metrics about enrichment progress, missing data, catalog value, and cache stats.
|
||||
"""
|
||||
try:
|
||||
daily_stats = await stats.get_daily_stats()
|
||||
cache_stats = await stats.get_brickset_cache_stats()
|
||||
|
||||
# Calculate completion percentage
|
||||
if daily_stats["total_items"] > 0:
|
||||
completion_rate = (
|
||||
daily_stats["enriched_items"] / daily_stats["total_items"] * 100
|
||||
)
|
||||
else:
|
||||
completion_rate = 0.0
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"version": "0.3.0",
|
||||
"git_commit": stats.get_git_commit(),
|
||||
"stats": daily_stats,
|
||||
"completion_rate": round(completion_rate, 1),
|
||||
"brickset_cache": cache_stats,
|
||||
}
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": str(e)},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/hook")
|
||||
async def webhook_handler(request: Request):
|
||||
"""
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import src.libsquare as sq
|
||||
import src.bricksetapi as lego
|
||||
|
||||
oursq = sq.get_itm("71417")
|
||||
ourlego = lego.get_set(71417)
|
||||
oursq = sq.get_itm("10291")
|
||||
ourlego = lego.get_set(10291)
|
||||
print(f"{oursq}\n\n{ourlego}\n\n")
|
||||
Reference in new issue
Block a user