17 Commits
Author SHA1 Message Date
C dcccdcd9ec fix pricing not being entered 2026-01-07 11:36:24 -08:00
C dbc1115249 fix hang 2026-01-07 11:25:45 -08:00
C dbe5cd6146 final commit before first prod test 2026-01-07 09:59:36 -08:00
C a6e2397046 fix prod hang 2026-01-05 16:41:25 -08:00
C 2da61539cc fix worker warning 2026-01-05 16:32:37 -08:00
C bf65965b26 fix banner 2026-01-05 16:26:19 -08:00
C bcf625d2b0 add stats, versioning 2026-01-05 16:23:44 -08:00
C dfd2c2f07d add caching, add to stats 2026-01-05 16:01:36 -08:00
C ab3297acfb fix env thing 2026-01-05 15:44:04 -08:00
C 90aca6cca4 hopefully fix workers not kloading the key 2026-01-05 15:42:54 -08:00
C 199995b947 fix key please 2026-01-05 15:37:50 -08:00
C 2f7769e2c3 imporerror 2026-01-05 15:27:34 -08:00
C 7ca181bdd6 Merge remote-tracking branch 'origin/rewrite' into rewrite 2026-01-05 15:24:55 -08:00
C 1e96dead07 fix imports and price 2026-01-05 15:24:50 -08:00
C 248950adb9 fix imports 2026-01-05 15:23:33 -08:00
C 832122e63c refactor and reorganize!! 2026-01-04 17:55:43 -08:00
C e7f10c4f59 stats yay 2026-01-04 16:00:25 -08:00
21 changed files with 1062 additions and 126 deletions

No files matched your search

+10 -3
View File
@@ -6,17 +6,24 @@ BRICKSET_API_KEY = "your_brickset_api_key"
# Square environment - "sandbox" for testing, "production" for live store # Square environment - "sandbox" for testing, "production" for live store
SQUARE_ENV = "sandbox" # or "production" 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 credentials - test environment (fake data, safe for development)
SANDBOX_SQUARE_APP_ID = "your_sandbox_app_id" SANDBOX_SQUARE_APP_ID = "your_sandbox_app_id"
SANDBOX_SQUARE_TOKEN = "your_sandbox_access_token" 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) # Production credentials - live store (real inventory and sales)
PROD_SQUARE_APP_ID = "your_production_app_id" PROD_SQUARE_APP_ID = "your_production_app_id"
PROD_SQUARE_TOKEN = "your_production_access_token" 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) # Category ID for LEGO items in Square catalog (find in Dashboard → Items → Categories)
SQUARE_LEGO_CAT_ID = "your_lego_category_id" SQUARE_LEGO_CAT_ID = "your_lego_category_id"
+1
View File
@@ -0,0 +1 @@
0.3.0
+26 -19
View File
@@ -1,8 +1,18 @@
import multiprocessing import multiprocessing
import os import os
import git
import src.banner as banner 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 # Server socket
bind = "127.0.0.1:7433" # nginx will proxy to this bind = "127.0.0.1:7433" # nginx will proxy to this
backlog = 2048 backlog = 2048
@@ -54,26 +64,13 @@ def on_starting(server):
"""Called just before the master process is initialized.""" """Called just before the master process is initialized."""
print("Studspider WEB starting...") print("Studspider WEB starting...")
# Load .env file before workers start # Verify .env file exists (workers will load it via get_sq_env.py)
import dotenv
from pathlib import Path from pathlib import Path
env_path = Path(__file__).parent.parent / ".env" env_path = Path(__file__).parent.parent / ".env"
print(f"[@DEBUG] Looking for .env at: {env_path.absolute()}") print(f"[@DEBUG] Looking for .env at: {env_path.absolute()}")
if env_path.exists(): if env_path.exists():
dotenv.load_dotenv(env_path) print(f"✓ .env file found (workers will load via get_sq_env.py)")
print(f"✓ Loaded environment from {env_path}")
# Verify key was loaded
import os
key = os.getenv("PROD_SQUARE_WEBHOOK_SIGNATURE_KEY") or os.getenv(
"SANDBOX_SQUARE_WEBHOOK_SIGNATURE_KEY"
)
if key:
print(f"✓ SQUARE_WEBHOOK_SIGNATURE_KEY loaded: {key[:10]}...")
else:
print(f"⚠ SQUARE_WEBHOOK_SIGNATURE_KEY not found in .env")
else: else:
print(f"⚠ No .env file found at {env_path.absolute()}") print(f"⚠ No .env file found at {env_path.absolute()}")
@@ -84,11 +81,21 @@ def on_reload(server):
def when_ready(server): def when_ready(server):
print(banner.generate("studspider v0.1")) 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("Built for PLAY-WELL TEKNOLOGIES by contact@studspider.sh\n\n")
print(f"STUDSPIDER HOOK v0.1 RUNNING ON {bind}") print("PRODUCTION MODE\n" if os.getenv("SQUARE_ENV") == "production" else "SANDBOX MODE\n")
print(f"WORKERS: {workers}") print(f"HOST:\t{bind}")
print(f"WORKING CLASS: {worker_class}\n") print(f"WORKERS:\t{workers}")
print(f"WORKER TYPE:\t{worker_class}")
print(f"{'=' * 60}\n")
def worker_int(worker): def worker_int(worker):
+3 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "studpider" name = "studpider"
version = "0.1.0" version = "0.3.0"
description = "" description = ""
authors = [{ name = "CF", email = "ph0sgene@pm.me" }] authors = [{ name = "CF", email = "ph0sgene@pm.me" }]
requires-python = ">=3.13,<3.14" requires-python = ">=3.13,<3.14"
@@ -21,6 +21,8 @@ dependencies = [
"fastapi>=0.128.0", "fastapi>=0.128.0",
"uvicorn>=0.40.0", "uvicorn>=0.40.0",
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
"async-lru>=2.0.4",
"gitpython>=3.1.0",
] ]
[build-system] [build-system]
+1 -1
View File
@@ -2,7 +2,7 @@ from setuptools import setup
setup( setup(
name="studspider", name="studspider",
version="0.1", version="0.3.0",
description="Bricket + Square integration application", description="Bricket + Square integration application",
author="CF", author="CF",
author_email="chris-forst@pm.me", author_email="chris-forst@pm.me",
+125 -12
View File
@@ -7,9 +7,13 @@ import asyncio
import os import os
import aiohttp import aiohttp
import json import json
import math
import dotenv 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 throttler
from . import get_sq_env
Throttler = throttler.GlobalThrottler Throttler = throttler.GlobalThrottler
@@ -19,16 +23,115 @@ dotenv.load_dotenv()
class AsyncBricksetAPI: class AsyncBricksetAPI:
"""Asynchronous client for the Brickset API""" """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.username = os.getenv("BRICKSET_USERNAME")
self.password = os.getenv("BRICKSET_PASS") self.password = os.getenv("BRICKSET_PASS")
self.api_key = os.getenv("BRICKSET_API_KEY") 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.headers = {"UserAgent": "studspider/0.1"}
self.throttler = Throttler(rate_limit=500, period=60) self.throttler = Throttler(rate_limit=500, period=60)
self.user_hash = None self.user_hash = None
self.logger = logger 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]: async def login(self) -> Dict[str, Any]:
"""Asynchronously authenticate with Brickset API""" """Asynchronously authenticate with Brickset API"""
url = "https://brickset.com/api/v3.asmx/login" url = "https://brickset.com/api/v3.asmx/login"
@@ -55,16 +158,27 @@ class AsyncBricksetAPI:
self.logger.info("Successfully authenticated with Brickset API") self.logger.info("Successfully authenticated with Brickset API")
return result 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]: 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: Args:
set_number (str): LEGO set number set_number (str): LEGO set number
Returns: Returns:
Dict containing set information 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: if not self.user_hash:
await self.login() await self.login()
@@ -106,22 +220,21 @@ class AsyncBricksetAPI:
barcode_data = set_data.get("barcode", {}) barcode_data = set_data.get("barcode", {})
upc_code = barcode_data.get("UPC") or barcode_data.get("EAN") 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 = { our_set = {
"set_num": set_data["number"], "set_num": set_data["number"],
"set_name": set_data["name"], "set_name": set_data["name"],
"set_theme": set_data["theme"], "set_theme": set_data["theme"],
"set_image": set_data["image"]["thumbnailURL"], "set_image": set_data["image"]["thumbnailURL"],
"set_barcode": upc_code, "set_barcode": upc_code,
"set_retailprice": round( "set_retailprice": round(retail_price),
set_data["LEGOCom"]["US"]["retailPrice"]
),
} }
# Calculate adjusted price (10% markup) # Calculate adjusted price (10% markup), then round up
retail_price = our_set["set_retailprice"] adjusted_price = retail_price * 1.10
our_set["set_adjustredprice"] = retail_price + ( our_set["set_adjustredprice"] = math.ceil(adjusted_price)
(10 / 100) * retail_price
)
# Set PW inventory name with theme # Set PW inventory name with theme
our_set["pw_name"] = ( our_set["pw_name"] = (
+29 -14
View File
@@ -4,6 +4,7 @@ import json
import asyncio import asyncio
import uuid import uuid
import re import re
import traceback
from time import time from time import time
import aiohttp import aiohttp
from io import BytesIO from io import BytesIO
@@ -123,8 +124,9 @@ async def get_item_by_variation_id(variation_id: str) -> dict:
cursor = None cursor = None
page_count = 0 page_count = 0
max_pages = 50 # Safety limit to prevent infinite loops
while True: while page_count < max_pages:
page_count += 1 page_count += 1
print(f"[@DEBUG] Searching page {page_count}...") print(f"[@DEBUG] Searching page {page_count}...")
@@ -150,6 +152,7 @@ async def get_item_by_variation_id(variation_id: str) -> dict:
raise ValueError(f"No items found in LEGO category") raise ValueError(f"No items found in LEGO category")
else: else:
# No more results # No more results
print(f"[@DEBUG] No more results on page {page_count}")
break break
# Search through items on this page to find which has this variation # Search through items on this page to find which has this variation
@@ -187,8 +190,15 @@ async def get_item_by_variation_id(variation_id: str) -> dict:
cursor = result_json.get("cursor") cursor = result_json.get("cursor")
if not cursor: if not cursor:
# No more pages # No more pages
print(f"[@DEBUG] No more pages after page {page_count}")
break 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( raise ValueError(
f"Variation {variation_id} not found in any LEGO category item (searched {page_count} pages)" f"Variation {variation_id} not found in any LEGO category item (searched {page_count} pages)"
) )
@@ -293,7 +303,6 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
pw_name = lego_set["pw_name"] pw_name = lego_set["pw_name"]
set_adjustedprice = lego_set["set_adjustredprice"] set_adjustedprice = lego_set["set_adjustredprice"]
# Convert price to cents for Square API
price_in_cents = int(set_adjustedprice * 100) price_in_cents = int(set_adjustedprice * 100)
# Retry logic for version conflicts (race conditions with concurrent updates) # Retry logic for version conflicts (race conditions with concurrent updates)
@@ -376,11 +385,19 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
"item_variation_data", {} "item_variation_data", {}
).copy() ).copy()
# Update only specific fields # Check if this variation has VARIABLE_PRICING
updated_var_data["price_money"] = { current_pricing_type = updated_var_data.get("pricing_type")
"amount": price_in_cents,
"currency": "USD", # 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["sku"] = set_number # LEGO set number
updated_var_data["upc"] = ( updated_var_data["upc"] = (
set_upc if set_upc else None set_upc if set_upc else None
@@ -395,7 +412,11 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
updated_variations.append(updated_var) updated_variations.append(updated_var)
variation_found = True variation_found = True
else: else:
# Keep other variations as-is # Keeprice_money": {
"amount": price_in_cents,
"currency": "USD",
},
"p other variations as-is
updated_variations.append(existing_var) updated_variations.append(existing_var)
# If variation wasn't found (shouldn't happen), create new one # If variation wasn't found (shouldn't happen), create new one
@@ -407,10 +428,6 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
"item_id": item_id, "item_id": item_id,
"name": "Regular", "name": "Regular",
"pricing_type": "FIXED_PRICING", "pricing_type": "FIXED_PRICING",
"price_money": {
"amount": price_in_cents,
"currency": "USD",
},
"sku": set_number, "sku": set_number,
"upc": set_upc if set_upc else None, "upc": set_upc if set_upc else None,
}, },
@@ -492,8 +509,6 @@ async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
except Exception as e: except Exception as e:
print(f"[@ERROR] Failed to update item {item_id}: {e}") print(f"[@ERROR] Failed to update item {item_id}: {e}")
import traceback
traceback.print_exc() traceback.print_exc()
return False return False
+9 -2
View File
@@ -1,14 +1,21 @@
import os import os
import dotenv import dotenv
from pathlib import Path
from square.environment import SquareEnvironment 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") LEGO_CAT_ID = os.getenv("SQUARE_LEGO_CAT_ID")
SQUARE_WEBHOOK_URL = os.getenv("SQUARE_WEBHOOK_URL") 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_APP_ID = os.getenv("PROD_SQUARE_APP_ID")
SQUARE_TOKEN = os.getenv("PROD_SQUARE_TOKEN") SQUARE_TOKEN = os.getenv("PROD_SQUARE_TOKEN")
SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("PROD_SQUARE_WEBHOOK_SIGNATURE_KEY") SQUARE_WEBHOOK_SIGNATURE_KEY = os.getenv("PROD_SQUARE_WEBHOOK_SIGNATURE_KEY")
+31 -25
View File
@@ -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}") print(f"[@DEBUG] Extracted set number: {set_number}")
# Fetch Brickset data (with throttling) # 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) lego_set = await brickset_client.throttled_get_set(set_number)
if logger: if logger:
@@ -104,24 +105,10 @@ async def enrich_single_item(item_id: str, item_data: dict = None, logger=None)
else: else:
print(f"[@DEBUG] Retrieved Brickset data for {lego_set['pw_name']}") print(f"[@DEBUG] Retrieved Brickset data for {lego_set['pw_name']}")
# Check if manual price review needed # Note: Price updates disabled - enriching without price
if lego_set.get("requires_manual_price"): # REVIEW table logic skipped
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( # Update Square item with enriched data (without price)
"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, item_data=item) success = await asquare.update_itm(item_id, lego_set, item_data=item)
if success: if success:
@@ -179,18 +166,37 @@ async def enrich_bulk(logger=None) -> None:
else: else:
print(f"[@DEBUG] Found {len(items)} items in LEGO category") print(f"[@DEBUG] Found {len(items)} items in LEGO category")
# Enrich each item # Enrich each item with concurrent batches
successful = 0 successful = 0
failed = 0 failed = 0
batch_size = 20 # Process 20 items concurrently
for item in items: for i in range(0, len(items), batch_size):
item_id = item["id"] batch = items[i : i + batch_size]
result = await enrich_single_item(item_id, logger=logger) batch_num = (i // batch_size) + 1
total_batches = (len(items) + batch_size - 1) // batch_size
if result: if logger:
successful += 1 logger.info(
f"Processing batch {batch_num}/{total_batches} ({len(batch)} items)"
)
else: 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: if logger:
logger.info( logger.info(
File renamed without changes.
File renamed without changes.
File renamed without changes.
+277
View File
@@ -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)
+426
View File
@@ -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 asyncio
import json import json
from . import asquare from .. import asquare
from . import get_sq_env from .. import get_sq_env
async def list_categories(): async def list_categories():
+9 -22
View File
@@ -95,30 +95,17 @@ async def migrate_sku_to_gtin():
print(f"[@INFO] Item {item_id} ({item_name})") print(f"[@INFO] Item {item_id} ({item_name})")
print(f"[@INFO] Variation {var_id}: Moving SKU '{sku}' to UPC") print(f"[@INFO] Variation {var_id}: Moving SKU '{sku}' to UPC")
# Build updated variation # Preserve all existing variation data and only modify what we need
updated_var = { updated_var = var.copy() # Shallow copy the entire variation
"type": "ITEM_VARIATION", updated_var_data = var_data.copy() # Copy the variation_data
"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 # Update only UPC and SKU fields
if "price_money" in var_data: updated_var_data["upc"] = sku # Move SKU value to UPC
updated_var["item_variation_data"]["price_money"] = var_data[ updated_var_data["sku"] = (
"price_money" 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) updated_variations.append(updated_var)
needs_update = True needs_update = True
else: else:
@@ -56,7 +56,7 @@ def get_set(set_number: int) -> dict:
{"set_adjustredprice": our_set + ((10 / 100) * our_set["set_retailprice"])} {"set_adjustredprice": our_set + ((10 / 100) * our_set["set_retailprice"])}
) )
our_set["pw_name"] = ( 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']}"
) )
if our_set["set_retailprice"] >= 110: if our_set["set_retailprice"] >= 110:
our_set["requires_manual_price"] = True our_set["requires_manual_price"] = True
File renamed without changes.
+70 -9
View File
@@ -4,15 +4,24 @@ Queries Square catalog to collect metrics about LEGO inventory quality.
""" """
import json import json
import git
from typing import Dict, Any from typing import Dict, Any
from square import AsyncSquare 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 # Initialize Square client
client = AsyncSquare( client = AsyncSquare(token=get_sq_env.SQUARE_TOKEN, environment=get_sq_env.environment)
token=get_sq_env.SQUARE_TOKEN,
environment=get_sq_env.environment
)
LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID
@@ -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: 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( result = await client.catalog.search(
object_types=["ITEM"], object_types=["ITEM"],
@@ -293,6 +352,8 @@ if __name__ == "__main__":
print(f"Missing UPCs: {stats['missing_upcs']}") print(f"Missing UPCs: {stats['missing_upcs']}")
print(f"Missing Images: {stats['missing_images']}") print(f"Missing Images: {stats['missing_images']}")
print(f"Total Catalog Value: ${stats['total_catalog_value']:,.2f}") 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()) asyncio.run(main())
+40 -13
View File
@@ -5,6 +5,7 @@ from typing import Dict, Any
from fastapi import FastAPI, Request, Response, status from fastapi import FastAPI, Request, Response, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import src.get_sq_env as sq_env import src.get_sq_env as sq_env
import src.stats as stats
# Square webhook validation # Square webhook validation
from square.utils.webhooks_helper import verify_signature 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_WEBHOOK_URL = sq_env.SQUARE_WEBHOOK_URL
SQUARE_LEGO_CAT_ID = sq_env.LEGO_CAT_ID 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) # In-memory store for processed event IDs (idempotency)
processed_event_ids = set() processed_event_ids = set()
MAX_STORED_EVENT_IDS = 10000 MAX_STORED_EVENT_IDS = 10000
@@ -40,7 +29,11 @@ async def root():
return { return {
"service": "Studspider", "service": "Studspider",
"status": "running", "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") @app.post("/hook")
async def webhook_handler(request: Request): async def webhook_handler(request: Request):
""" """
+2 -2
View File
@@ -1,6 +1,6 @@
import src.libsquare as sq import src.libsquare as sq
import src.bricksetapi as lego import src.bricksetapi as lego
oursq = sq.get_itm("71417") oursq = sq.get_itm("10291")
ourlego = lego.get_set(71417) ourlego = lego.get_set(10291)
print(f"{oursq}\n\n{ourlego}\n\n") print(f"{oursq}\n\n{ourlego}\n\n")