57 Commits
Author SHA1 Message Date
C 94af7d9d89 derp 2026-01-04 14:08:19 -08:00
C f652b108f4 whoops 2026-01-04 14:08:10 -08:00
C 4a88f1ec53 only one var 2026-01-04 14:03:43 -08:00
C 87ed932f95 square is so fucking annoying dude 2026-01-04 13:57:11 -08:00
C fce743d580 vfrs 2026-01-04 13:53:07 -08:00
C 1352c7bb81 vfd 2026-01-04 13:49:50 -08:00
C 7fe6ac2331 were going in circles again 2026-01-04 13:45:43 -08:00
C 931341751d omg bro 2026-01-04 13:43:09 -08:00
C 48a8a5ce54 man 2026-01-04 13:38:48 -08:00
C 7296927cea dfds 2026-01-04 13:36:32 -08:00
C a2746737ce NO LOCATION AAAAH 2026-01-04 13:34:51 -08:00
C 0f68b09a00 bfdafdsa 2026-01-04 13:31:08 -08:00
C 58618429db cmon man 2026-01-04 13:29:22 -08:00
C b988db11ec location location location! 2026-01-04 13:26:45 -08:00
C bf7ebdab05 bro all locations what 2026-01-04 13:25:48 -08:00
C d7ed797cda what the helly 2026-01-04 13:20:07 -08:00
C 7954b0b70d fix cat issue 2026-01-04 13:16:31 -08:00
C 97174b2080 mismatch 2026-01-04 12:43:39 -08:00
C 0089ce8556 fdsadsa 2026-01-04 12:41:28 -08:00
C bcb4e65585 horse race tests 2026-01-04 12:40:06 -08:00
C 734d43b817 s 2026-01-04 12:38:16 -08:00
C 53f221884a vrasdfas 2026-01-04 12:36:42 -08:00
C 5f4bbb2565 dssfgfds 2026-01-04 12:34:43 -08:00
C da382942d3 very confusing lol 2026-01-04 12:30:59 -08:00
C 16d0cd85c2 dfas 2026-01-04 12:27:18 -08:00
C e10c275cbc VERSION THESE NUTS 2026-01-04 12:23:45 -08:00
C 5cbbed1992 SQUARE THESE NUTS 2026-01-04 12:21:15 -08:00
C 8a1856448d RAAAA 2026-01-04 12:18:55 -08:00
C cff03d5eee WE ARE SO CLOSE 2026-01-04 12:17:19 -08:00
C 3e21b904a2 throttler fix 2026-01-04 12:12:09 -08:00
C 535826fce6 FIXXXXX 2026-01-04 12:02:55 -08:00
C 30b4b26c9e CATEGORY ID IS NONE?????? 2026-01-04 11:56:27 -08:00
C d07f66ca57 okay man so whats the category 2026-01-04 11:49:31 -08:00
C 424d7795bc pagination fix 2026-01-04 11:44:30 -08:00
C 6c0d15dec9 dsadgdfgdsgdsf 2026-01-04 11:41:26 -08:00
C 6d376c3653 i think were going in circles 2026-01-04 11:38:02 -08:00
C 8c11958d95 test 2026-01-04 11:36:07 -08:00
C 187bf1c111 grr 2026-01-04 11:33:48 -08:00
C 4f425bfca0 vro 2026-01-04 11:30:50 -08:00
C bb1c421df1 import errors? 2026-01-04 11:29:35 -08:00
C 8e21780fb4 whoops 2026-01-04 11:27:25 -08:00
C d341684567 test glue 2026-01-04 11:18:43 -08:00
C 03f6e84965 name changes 2026-01-04 10:41:18 -08:00
C bb6704f3aa fix asquare to match brickset 2026-01-04 10:41:10 -08:00
C 63a8c29105 reporting stats 2026-01-04 10:40:43 -08:00
C 84cb6becf9 update sq env 2026-01-04 10:40:29 -08:00
C 48835cd857 im actually gonna crash out rn dude 2026-01-03 17:24:49 -08:00
C c4bca1e4d0 bro my grammar was so ass. was i drunk when i wrote this? 2026-01-03 16:45:00 -08:00
C 42b4575cc4 update env 2026-01-03 16:34:19 -08:00
C a51c19ea53 complete project restructure 2026-01-03 16:08:48 -08:00
C a560273c3f we are branching again,...... 2026-01-03 15:51:24 -08:00
C d95ce1dc7a fix?!??!?! 2026-01-03 12:50:36 -08:00
C bd8dd3b93d forgot the conf lol 2026-01-02 17:00:41 -08:00
C b3e246a6ce lets try gunicorn 2026-01-02 16:58:18 -08:00
C 57a5afbe48 stupid typo 2026-01-02 16:19:15 -08:00
C 4320b4cba9 fix webhook code we hope 2026-01-02 14:28:37 -08:00
C a8ce535985 were gonna make this webhook shit WORK 2025-12-21 17:49:51 -08:00
22 changed files with 2390 additions and 827 deletions

No files matched your search

+17 -67
View File
@@ -1,72 +1,22 @@
# studspider Environment Configuration # Brickset.com credentials - LEGO database for pricing, images, UPC codes
BRICKSET_USERNAME = "your_brickset_username"
BRICKSET_PASS = "your_brickset_password"
BRICKSET_API_KEY = "your_brickset_api_key"
# ============================================================================ # Square environment - "sandbox" for testing, "production" for live store
# Square API Configuration SQUARE_ENV = "sandbox" # or "production"
# ============================================================================
# Environment: "production" or "sandbox" SQUARE_WEBHOOK_URL = "https://yourdomain.com/hook"
SQUARE_ENV=sandbox
# Sandbox credentials # 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"
# Production credentials # 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"
# Square LEGO category ID (find this in Square Dashboard) # 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"
# ============================================================================
# Brickset API Configuration
# ============================================================================
BRICKSET_USERNAME=your_brickset_username
BRICKSET_PASS=your_brickset_password
BRICKSET_API_KEY=your_brickset_api_key
# ============================================================================
# SSL Certificate Configuration (Optional)
# ============================================================================
# NOTE: Only needed if running HTTPS directly (not recommended with nginx)
# For nginx reverse proxy setup, leave these empty
# Path to SSL certificate in PEM format
# SSL_CERT_PATH=/path/to/cert.pem
# Path to SSL private key in PEM format
# SSL_KEY_PATH=/path/to/key.pem
# ============================================================================
# Square Webhook Certificate (P7B Format)
# ============================================================================
# Path to P7B certificate file for Square webhook signature verification
# This file is UPLOADED to Square Dashboard, not used by your server
# Generate with: python convert_to_p7b.py
SQUARE_P7B_PATH=./cert.p7b
# ============================================================================
# Server Configuration (Optional)
# ============================================================================
# Default host and port (can override with command line args)
# SERVER_HOST=0.0.0.0
# SERVER_PORT=7432
# SERVER_HOOK_PORT=8080
# ============================================================================
# Usage Examples
# ============================================================================
# Basic HTTP server (recommended with nginx):
# python src/test_server.py
#
# With SSL from .env:
# Set SSL_CERT_PATH and SSL_KEY_PATH above, then:
# python src/test_server.py --hook-port 8443 --hook-ssl-only
#
# Override SSL from command line:
# python src/test_server.py --ssl-cert /custom/cert.pem --ssl-key /custom/key.pem
+4 -1
View File
@@ -37,4 +37,7 @@ win/
.github/ .github/
bin/ bin/
activate/ activate/
build/ build/
.pem
.p7b
nginx/
+6 -5
View File
@@ -5,14 +5,14 @@
is a tool used to talk to brickset.com and a square inventory to pull lego set information for entering in the square db is a tool used to talk to brickset.com, pull lego set information and automatically enter it into the square inventory!
## Run me ## Run me
Clone the project Clone the project
```bash ```bash
git clone http://git.traps.moe/ph0sgene/studspider.git git clone http://git.traps.moe/ph0sgene/studspider.git
git checkout uv git checkout branch
``` ```
Go to the project directory Go to the project directory
@@ -30,14 +30,15 @@ Install dependencies
Start the server Start the server
```bash ```bash
uvx run studspider.py uv run studspider.py
``` ```
## Tech ## Tech
- gunicorn
- uvicorn
- aiohttp - aiohttp
- squareup - squareup
- apscheduler - apscheduler
- aiosqlite - aiosqlite
+86
View File
@@ -0,0 +1,86 @@
import multiprocessing
import os
# Server socket
bind = "127.0.0.1:7433" # nginx will proxy to this
backlog = 2048
# Worker processes
workers = multiprocessing.cpu_count() * 2 + 1 # Recommended formula
worker_class = "uvicorn.workers.UvicornWorker" # Async worker for FastAPI
worker_connections = 1000
max_requests = 1000 # Restart workers after this many requests
max_requests_jitter = 50 # Add randomness to prevent all workers restarting at once
# Timeouts
timeout = 30 # Workers silent for more than this are killed
keepalive = 2 # Wait this long for requests on Keep-Alive connections
# Logging
accesslog = "./logs/webhook_access.log"
errorlog = "./logs/webhook_error.log"
loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
# Process naming
proc_name = "studspider"
# Server mechanics
daemon = False # Run in foreground (systemd will handle daemonization)
pidfile = "./logs/studspider.pid"
umask = 0
user = None # Run as current user (systemd will set user)
group = None
tmp_upload_dir = None
# SSL (not needed with nginx, but here for reference)
# keyfile = None
# certfile = None
# Security
limit_request_line = 4096
limit_request_fields = 100
limit_request_field_size = 8190
# Debugging (disable in production)
reload = os.getenv("GUNICORN_RELOAD", "false").lower() == "true"
reload_engine = "auto"
# Hooks
def on_starting(server):
"""Called just before the master process is initialized."""
print("Studspider WEB starting...")
# Load .env file before workers start
import dotenv
from pathlib import Path
env_path = Path(__file__).parent.parent / ".env"
if env_path.exists():
dotenv.load_dotenv(env_path)
print(f"✓ Loaded environment from {env_path}")
else:
print(f"⚠ No .env file found at {env_path}")
def on_reload(server):
"""Called to recycle workers during a reload via SIGHUP."""
print("🔄 Reloading workers...")
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")
def worker_int(worker):
"""Called just after a worker exited on SIGINT or SIGQUIT."""
print(f"⚠️ Worker {worker.pid} received INT or QUIT signal")
def worker_abort(worker):
"""Called when a worker received the SIGABRT signal."""
print(f"❌ Worker {worker.pid} aborted")
-11
View File
@@ -1,11 +0,0 @@
{
"folders": [
{
"path": "."
},
{
"path": "../kopling"
}
],
"settings": {}
}
+3
View File
@@ -18,6 +18,9 @@ dependencies = [
"aiosqlite (>=0.21.0,<0.22.0)", "aiosqlite (>=0.21.0,<0.22.0)",
"bs4>=0.0.2", "bs4>=0.0.2",
"reflex>=0.8.14.post1", "reflex>=0.8.14.post1",
"fastapi>=0.128.0",
"uvicorn>=0.40.0",
"gunicorn>=23.0.0",
] ]
[build-system] [build-system]
+1 -1
View File
@@ -9,7 +9,7 @@ import aiohttp
import json import json
import dotenv import dotenv
from typing import Dict, Any from typing import Dict, Any
import throttler from . import throttler
Throttler = throttler.GlobalThrottler Throttler = throttler.GlobalThrottler
+347 -17
View File
@@ -6,9 +6,10 @@ import uuid
import re import re
from time import time from time import time
import db from square.core.api_error import ApiError
import get_sq_env from . import db
from . import get_sq_env
def get_set_number(text: str) -> int: def get_set_number(text: str) -> int:
@@ -51,33 +52,362 @@ async def get_itm(sku: str):
return {"name": name, "id": itm_id} return {"name": name, "id": itm_id}
async def update_itm(lego_set: dict): async def get_item_by_id(item_id: str) -> dict:
"""
Retrieve full item details by Square catalog item ID.
Uses batch_get API for direct ID lookup.
Args:
item_id: Square catalog item ID
Returns:
dict: Item details including name, category_id, variations, etc.
Raises:
ValueError: If item not found
Exception: If API error
"""
try:
# Use batch_get to retrieve item directly by ID
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, # Return the entire item_data object
"updated_at": obj.get("updated_at"),
"version": obj.get("version"),
}
except ValueError:
raise
except Exception as e:
print(f"[@ERROR] Failed to get item {item_id}: {e}")
raise
async def get_item_by_variation_id(variation_id: str) -> dict:
"""
Retrieve parent item details from a variation ID.
Webhooks send variation IDs (inventory.count.updated), but we need
the parent item to extract the set number from the name.
Since async Square API lacks direct object retrieval, we search all items
in the LEGO category and match variations.
Args:
variation_id: Square catalog item variation ID
Returns:
dict: Parent item details
Raises:
Exception: If variation/item not found or API error
"""
try:
# Search LEGO category items to find which one has this variation
print(f"[@DEBUG] Searching for variation {variation_id} in LEGO category")
cursor = None
page_count = 0
while True:
page_count += 1
print(f"[@DEBUG] Searching page {page_count}...")
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(f"No items found in LEGO category")
else:
# No more results
break
# Search through items on this page to find which has this 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 the parent item!
print(
f"[@DEBUG] Found parent item: {obj['id']} on page {page_count}"
)
# Since we searched by category, we know it's in LEGO_CAT_ID
# But also try to get from item_data as fallback
category_id = item_data.get("category_id") or LEGO_CAT_ID
print(
f"[@DEBUG] Item category_id from data: {item_data.get('category_id')}"
)
print(f"[@DEBUG] Using category_id: {category_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"),
}
# Check if there are more pages
cursor = result_json.get("cursor")
if not cursor:
# No more pages
break
raise ValueError(
f"Variation {variation_id} not found in any LEGO category item (searched {page_count} pages)"
)
except Exception as e:
print(f"[@ERROR] Failed to get item from variation {variation_id}: {e}")
raise
async def update_itm(item_id: str, lego_set: dict, item_data: dict = None):
""" """
Asynchronously updates an item in the Square catalog based on the provided LEGO set information. Asynchronously updates an item in the Square catalog based on the provided LEGO set information.
Args: Args:
lego_set (dict): A dictionary containing LEGO set details. Expected keys include: item_id (str): Square catalog item ID to update
- "sets": List of set dictionaries, each with "name", "number", "barcode" (with "UPC"), and "image" (with "thumbnailURL"). lego_set (dict): A dictionary containing LEGO set details from abricksetapi. Expected keys include:
- "pw_name": The name associated with the LEGO set in another system. - "set_num": LEGO set number (e.g., "75192")
- "set_adjustredprice": The adjusted price for the set. - "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 for Square inventory (e.g., "75192 - STAR WARS UCS Millennium Falcon")
- "set_adjustredprice": Retail price with 10% markup applied
item_data (dict): Optional pre-fetched item data to avoid redundant API call
Returns: Returns:
bool: True if the update operation was successful. bool: True if the update operation was successful.
Raises: Raises:
KeyError: If expected keys are missing from the input dictionary. KeyError: If expected keys are missing from the input dictionary.
Exception: For errors during Square API interaction. Exception: For errors during Square API interaction.
""" """
try:
set_number = lego_set["set_num"]
set_name = lego_set["set_name"]
set_theme = lego_set["set_theme"]
set_upc = lego_set["set_barcode"]
set_image = lego_set["set_image"]
pw_name = lego_set["pw_name"]
set_adjustedprice = lego_set["set_adjustredprice"]
set_name = lego_set["sets"][0]["name"] # Convert price to cents for Square API
set_number = lego_set["sets"][0]["number"] price_in_cents = int(set_adjustedprice * 100)
set_upc = lego_set["sets"][0]["barcode"]["UPC"]
set_image = lego_set["sets"][0]["image"]["thumbnailURL"]
pw_name = lego_set["pw_name"]
set_adjustedprice = lego_set["set_adjustredprice"]
result = await client.catalog.search_items(text_filter=set_number, limit=1) # Retry logic for version conflicts (race conditions with concurrent updates)
itm_id = json.loads(result.model_dump_json())["items"][0]["id"] max_retries = 3
retry_delay = 0.5 # seconds
# await sq.catalog.object.upsert() for attempt in range(max_retries):
return True try:
# ALWAYS fetch fresh item data right before update to get latest version
print(
f"[@DEBUG] Fetching fresh item data (attempt {attempt + 1}/{max_retries})..."
)
fresh_item = await get_item_by_id(item_id)
except ValueError as e:
# Item was deleted between webhook and enrichment
print(f"[@WARNING] {e} - skipping update (item may have been deleted)")
return
variations = fresh_item.get("variations", [])
if not variations:
print(
f"[@WARNING] Item {item_id} has no variations, creating new variation"
)
variation_id = None
variation_version = None
present_at_location_ids = []
present_at_all_locations = False
else:
variation_id = variations[0]["id"]
variation_version = variations[0].get("version")
# Check if variation is at all locations
present_at_all_locations = variations[0].get(
"present_at_all_locations", False
)
# Extract location IDs from variation (only if not at all locations)
present_at_location_ids = variations[0].get(
"present_at_location_ids", []
)
# Get current version for version control
current_version = fresh_item.get("version")
# CRITICAL: batch_get NEVER returns category_id (always None)
# We MUST use the category from initial item_data (from search which includes category)
# or fallback to LEGO_CAT_ID since we know this is a LEGO item
if item_data and item_data.get("category_id"):
category_id = item_data.get("category_id")
print(f"[@DEBUG] Using category_id from item_data: {category_id}")
else:
# Fallback to LEGO_CAT_ID (we know it's a LEGO item if enrichment was triggered)
category_id = LEGO_CAT_ID
print(
f"[@WARNING] No category_id in item_data, using LEGO_CAT_ID: {LEGO_CAT_ID}"
)
print(f"[@DEBUG] Fresh item version: {current_version}")
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
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, keep all others unchanged
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:
# Replace this variation with our updated one
updated_variations.append(variation_obj)
variation_found = True
else:
# Keep 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)
existing_item_data["variations"] = updated_variations
item_obj = {
"type": "ITEM",
"id": item_id,
"version": current_version,
"item_data": existing_item_data,
}
upsert_body = {
"idempotency_key": str(uuid.uuid4()),
"object": item_obj,
}
# Remove None values
if not set_upc:
del variation_obj["item_variation_data"]["sku"]
print(f"[@DEBUG] Upserting item {item_id} with name: {pw_name}")
print(f"[@DEBUG] Upsert body: {json.dumps(upsert_body, indent=2)}")
# Perform upsert using catalog.object.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"):
print(f"[@DEBUG] ✓ Successfully updated item {item_id}")
# DEBUG: Check what Square actually returned
returned_item = result_json["catalog_object"]
returned_category = returned_item.get("item_data", {}).get(
"category_id"
)
print(f"[@DEBUG] Square returned category_id: {returned_category}")
# TODO: Handle image upload separately (Square Images API)
# This requires multipart/form-data upload
return True
else:
print(
f"[@ERROR] Failed to update item {item_id}: No catalog_object in response"
)
return False
except ApiError as e:
# Check if this is a VERSION_MISMATCH error
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:
print(
f"[@WARNING] Version conflict on attempt {attempt + 1}, retrying in {retry_delay}s..."
)
await asyncio.sleep(retry_delay)
retry_delay *= 2 # Exponential backoff
continue # Retry with fresh data
else:
print(
f"[@ERROR] Version conflict persists after {max_retries} attempts, giving up"
)
return False
# Re-raise other API errors
raise
except Exception as e:
print(f"[@ERROR] Failed to update item {item_id}: {e}")
import traceback
traceback.print_exc()
return False
async def acat2id(cat_id: str): async def acat2id(cat_id: str):
+6 -8
View File
@@ -6,19 +6,17 @@ dotenv.load_dotenv()
LEGO_CAT_ID = os.getenv("SQUARE_LEGO_CAT_ID") LEGO_CAT_ID = os.getenv("SQUARE_LEGO_CAT_ID")
# SSL Certificate paths (optional - for direct HTTPS, not needed with nginx) SQUARE_WEBHOOK_URL = os.getenv("SQUARE_WEBHOOK_URL")
SSL_CERT_PATH = os.getenv("SSL_CERT_PATH")
SSL_KEY_PATH = os.getenv("SSL_KEY_PATH")
# P7B certificate for Square webhook signature verification
# This is uploaded to Square Dashboard, not used by the server
SQUARE_P7B_PATH = os.getenv("SQUARE_P7B_PATH")
if os.getenv("SQUARE_ENV") == "production": if os.getenv("SQUARE_ENV") == "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_SUB_ID = os.getenv("PROD_SQUARE_WEBHOOK_SUB_ID")
environment = SquareEnvironment.PRODUCTION environment = SquareEnvironment.PRODUCTION
else: else:
SQUARE_APP_ID = os.getenv("SANDBOX_SQUARE_APP_ID") SQUARE_APP_ID = os.getenv("SANDBOX_SQUARE_APP_ID")
SQUARE_TOKEN = os.getenv("SANDBOX_SQUARE_TOKEN") SQUARE_TOKEN = os.getenv("SANDBOX_SQUARE_TOKEN")
environment = SquareEnvironment.SANDBOX 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
+196 -26
View File
@@ -8,34 +8,204 @@ from time import time
from . import asquare from . import asquare
from . import db from . import db
from . import abricksetapi from . import abricksetapi
from . import throttler from . import throttler
Throttler = throttler.GlobalThrottler Throttler = throttler.GlobalThrottler
LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID LEGO_CAT_ID = get_sq_env.LEGO_CAT_ID
# big glue boy
async def do_review() -> bool: async def enrich_single_item(item_id: str, item_data: dict = None, logger=None) -> bool:
result = await asquare.acat2id(LEGO_CAT_ID) # type: ignore r"""
for item in result: Enrich a single Square catalog item with Brickset data.
if not re.match(r".*\d{5}.*", item["name"]):
missing = [] Flow:
print(f"{item} is missing its set number!") 1. Get item details from Square (by item_id) - skip if item_data provided
#print(f"[@DEBUG] Upserting {item['id']} into REVIEW table") 2. Extract 5-digit set number from name (regex \d{5})
item_sanitized = { 3. Fetch Brickset data (with throttling)
"sq_object": item["id"], 4. Update Square item with enriched data
"set_name": item["name"], 5. Handle errors (no set number → MISSING table)
"set_number": None,
"time_added": time Args:
} item_id: Square catalog item ID
missing.append(item_sanitized) item_data: Optional pre-fetched item data (avoids redundant API call)
logger: Optional logger instance for debug output
for item in missing:
print(item) Returns:
# sq_item = await db.fetch(item["id"]) True if successful, False otherwise
if item["id"] != sq_item["sq_object"]: """
print(f"[@DEBUG] {item['id']} is missing)") try:
#await db.upsert("MISSING", item_sanitized, "sq_object") if logger:
# #print(f"[@DEBUG] Upserted {item['id']} into REVIEW table") logger.info(f"Starting enrichment for item {item_id}")
#print(f"[@DEBUG] {item['id']} already in REVIEW table") else:
return True print(f"[@DEBUG] Starting enrichment for item {item_id}")
# Get item details from Square (only if not already provided)
if item_data is None:
item = await asquare.get_item_by_id(item_id)
else:
item = item_data
item_name = item.get("name", "")
if logger:
logger.debug(f"Item name: {item_name}")
else:
print(f"[@DEBUG] Item name: {item_name}")
# Extract 5-digit set number
set_numbers = re.findall(r"\d{5}", item_name)
if not set_numbers:
# No set number found - log to MISSING table
if logger:
logger.warning(f"No set number found in item {item_id}: {item_name}")
else:
print(f"[@WARNING] No set number found in item {item_id}: {item_name}")
await db.upsert(
"MISSING",
{
"sq_object": item_id,
"time_added": int(time()),
"set_number": None,
"set_name": item_name,
},
"sq_object",
)
return False
set_number = set_numbers[0]
# Check if item is already enriched (format: "XXXXX - THEME Name")
# Pattern: 5 digits, space, dash, space, uppercase theme
if re.match(r"^\d{5}\s-\s[A-Z]", item_name):
if logger:
logger.info(f"Item {item_id} already enriched, skipping: {item_name}")
else:
print(
f"[@DEBUG] Item {item_id} already enriched, skipping: {item_name}"
)
return True
if logger:
logger.info(f"Extracted set number: {set_number}")
else:
print(f"[@DEBUG] Extracted set number: {set_number}")
# Fetch Brickset data (with throttling)
brickset_client = abricksetapi.AsyncBricksetAPI(debug=True, logger=logger)
lego_set = await brickset_client.throttled_get_set(set_number)
if logger:
logger.info(f"Retrieved Brickset data for {lego_set['pw_name']}")
else:
print(f"[@DEBUG] Retrieved Brickset data for {lego_set['pw_name']}")
# Check if manual price review needed
if lego_set.get("requires_manual_price"):
if logger:
logger.info(f"Item {item_id} requires manual price review (≥$110)")
else:
print(f"[@DEBUG] Item {item_id} requires manual price review")
await db.upsert(
"REVIEW",
{
"sq_object": item_id,
"price": lego_set["set_adjustredprice"],
"date_modified": int(time()),
},
"sq_object",
)
# Update Square item with enriched data
success = await asquare.update_itm(item_id, lego_set, item_data=item)
if success:
if logger:
logger.info(f"✓ Successfully enriched item {item_id}")
else:
print(f"[@DEBUG] ✓ Successfully enriched item {item_id}")
return True
else:
if logger:
logger.error(f"Failed to update Square item {item_id}")
else:
print(f"[@ERROR] Failed to update Square item {item_id}")
return False
except ValueError as e:
if logger:
logger.error(f"Value error enriching item {item_id}: {e}")
else:
print(f"[@ERROR] Value error enriching item {item_id}: {e}")
return False
except Exception as e:
if logger:
logger.error(f"Unexpected error enriching item {item_id}: {e}")
else:
print(f"[@ERROR] Unexpected error enriching item {item_id}: {e}")
import traceback
traceback.print_exc()
return False
async def enrich_bulk(logger=None) -> None:
"""
Enrich all items in the LEGO category (bulk operation).
This is the refactored RUNUPDATE command logic.
Iterates all items in LEGO category and enriches each one.
Args:
logger: Optional logger instance for debug output
"""
try:
if logger:
logger.info("Starting bulk enrichment for LEGO category")
else:
print(f"[@DEBUG] Starting bulk enrichment for LEGO category {LEGO_CAT_ID}")
# Get all items in LEGO category
items = await asquare.acat2id(LEGO_CAT_ID)
if logger:
logger.info(f"Found {len(items)} items in LEGO category")
else:
print(f"[@DEBUG] Found {len(items)} items in LEGO category")
# Enrich each item
successful = 0
failed = 0
for item in items:
item_id = item["id"]
result = await enrich_single_item(item_id, logger=logger)
if result:
successful += 1
else:
failed += 1
if logger:
logger.info(
f"Bulk enrichment complete: {successful} successful, {failed} failed"
)
else:
print(
f"[@DEBUG] Bulk enrichment complete: {successful} successful, {failed} failed"
)
except Exception as e:
if logger:
logger.error(f"Error during bulk enrichment: {e}")
else:
print(f"[@ERROR] Error during bulk enrichment: {e}")
import traceback
traceback.print_exc()
+89
View File
@@ -0,0 +1,89 @@
"""
List all Square categories with their IDs to help identify the correct LEGO category ID
"""
import asyncio
import json
from . import asquare
from . import get_sq_env
async def list_categories():
"""List all categories in Square catalog"""
try:
print("\n" + "=" * 60)
print("SQUARE CATALOG CATEGORIES")
print("=" * 60 + "\n")
cursor = None
page_count = 0
total_categories = 0
while True:
page_count += 1
print(f"Fetching page {page_count}...")
search_params = {
"object_types": ["CATEGORY"],
"limit": 1000,
}
if cursor:
search_params["cursor"] = cursor
result = await asquare.client.catalog.search(**search_params)
result_json = json.loads(result.model_dump_json())
if not result_json.get("objects"):
if page_count == 1:
print("No categories found!")
break
for obj in result_json["objects"]:
category_data = obj.get("category_data", {})
category_id = obj.get("id")
category_name = category_data.get("name", "Unnamed")
print(f"\nCategory: {category_name}")
print(f" ID: {category_id}")
# Count items in this category
items_result = await asquare.client.catalog.search(
object_types=["ITEM"],
query={
"exact_query": {
"attribute_name": "category_id",
"attribute_value": category_id,
}
},
limit=1,
)
items_json = json.loads(items_result.model_dump_json())
item_count = len(items_json.get("objects", []))
print(f" Items: {item_count}+ items")
total_categories += 1
cursor = result_json.get("cursor")
if not cursor:
break
print("\n" + "=" * 60)
print(f"Total categories found: {total_categories}")
print("=" * 60 + "\n")
# Show current configured category
print(f"Currently configured LEGO_CAT_ID: {get_sq_env.LEGO_CAT_ID}")
print("\nUpdate .env file with the correct category ID if needed:")
print('SQUARE_LEGO_CAT_ID = "YOUR_CATEGORY_ID_HERE"')
print()
except Exception as e:
print(f"Error listing categories: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(list_categories())
+116 -116
View File
@@ -1,116 +1,116 @@
import os import os
import requests import requests
import json import json
import dotenv import dotenv
from typing import Dict, Any from typing import Dict, Any
dotenv.load_dotenv() dotenv.load_dotenv()
class BricksetAPI: class BricksetAPI:
"""Class for interacting with the Brickset API""" """Class for interacting with the Brickset API"""
def __init__(self, debug: bool = False): def __init__(self, debug: bool = False):
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 self.debug = debug
self.headers = {"UserAgent": "studspider/0.1"} self.headers = {"UserAgent": "studspider/0.1"}
self.user_hash = None self.user_hash = None
def login(self) -> Dict[str, Any]: def login(self) -> Dict[str, Any]:
"""Login to Brickset API and get user hash""" """Login to Brickset API and get user hash"""
url = "https://brickset.com/api/v3.asmx/login" url = "https://brickset.com/api/v3.asmx/login"
params = { params = {
"username": self.username, "username": self.username,
"password": self.password, "password": self.password,
"apiKey": self.api_key, "apiKey": self.api_key,
} }
response = requests.get(url, params=params, headers=self.headers) response = requests.get(url, params=params, headers=self.headers)
if self.debug: if self.debug:
print(f"[@DEBUG] Login response: {response.status_code}") print(f"[@DEBUG] Login response: {response.status_code}")
print(f"[@DEBUG] Response content: {response.text}") print(f"[@DEBUG] Response content: {response.text}")
try: try:
data = response.json() data = response.json()
self.user_hash = data.get("hash") self.user_hash = data.get("hash")
if not self.user_hash: if not self.user_hash:
raise ValueError("No hash received in login response") raise ValueError("No hash received in login response")
return data return data
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
print(f"[@ERROR] Failed to decode JSON response: {e}") print(f"[@ERROR] Failed to decode JSON response: {e}")
print(f"[@DEBUG] Raw response: {response.text}") print(f"[@DEBUG] Raw response: {response.text}")
raise raise
def get_set(self, set_number: str) -> Dict[str, Any]: def get_set(self, set_number: str) -> Dict[str, Any]:
"""Get LEGO set information by set number""" """Get LEGO set information by set number"""
if not self.user_hash: if not self.user_hash:
self.login() self.login()
url = "https://brickset.com/api/v3.asmx/getSets" url = "https://brickset.com/api/v3.asmx/getSets"
params = { params = {
"apiKey": self.api_key, "apiKey": self.api_key,
"userHash": self.user_hash, "userHash": self.user_hash,
"params": json.dumps({"setNumber": f"{set_number}-1"}), "params": json.dumps({"setNumber": f"{set_number}-1"}),
} }
response = requests.get(url, params=params, headers=self.headers) response = requests.get(url, params=params, headers=self.headers)
if self.debug: if self.debug:
print(f"[@DEBUG] Get set response: {response.status_code}") print(f"[@DEBUG] Get set response: {response.status_code}")
print(f"[@DEBUG] Response content: {response.text}") print(f"[@DEBUG] Response content: {response.text}")
if response.status_code == 200: if response.status_code == 200:
try: try:
data = response.json() data = response.json()
if not data.get("sets"): if not data.get("sets"):
raise ValueError(f"No set data found for {set_number}") raise ValueError(f"No set data found for {set_number}")
set_data = data["sets"][0] set_data = data["sets"][0]
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_image": set_data["image"]["thumbnailURL"], "set_image": set_data["image"]["thumbnailURL"],
"set_barcode": set_data["barcode"]["UPC"], "set_barcode": set_data["barcode"]["UPC"],
"set_retailprice": round(set_data["LEGOCom"]["US"]["retailPrice"]), "set_retailprice": round(set_data["LEGOCom"]["US"]["retailPrice"]),
} }
# Calculate adjusted price (10% markup) # Calculate adjusted price (10% markup)
retail_price = our_set["set_retailprice"] retail_price = our_set["set_retailprice"]
our_set["set_adjustredprice"] = retail_price + ( our_set["set_adjustredprice"] = retail_price + (
(10 / 100) * retail_price (10 / 100) * retail_price
) )
# Set Playwell inventory name # Set Playwell inventory name
if our_set["theme"] == "Duplo": if our_set["theme"] == "Duplo":
our_set["pw_name"] = ( our_set["pw_name"] = (
f"{our_set['set_num']} - DUPLO {our_set['set_name']}" f"{our_set['set_num']} - DUPLO {our_set['set_name']}"
) )
else: else:
our_set["pw_name"] = f"{our_set['set_num']} - {our_set['set_name']}" our_set["pw_name"] = f"{our_set['set_num']} - {our_set['set_name']}"
return our_set return our_set
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
# print(f"[@ERROR] Failed to decode JSON response: {e}") # print(f"[@ERROR] Failed to decode JSON response: {e}")
# print(f"[@DEBUG] Raw response: {response.text}") # print(f"[@DEBUG] Raw response: {response.text}")
raise raise
except KeyError as e: except KeyError as e:
# print(f"[@ERROR] Missing expected field in response: {e}") # print(f"[@ERROR] Missing expected field in response: {e}")
# print(f"[@DEBUG] Response data: {data}") # print(f"[@DEBUG] Response data: {data}")
raise raise
elif response.status_code == 500: elif response.status_code == 500:
raise Exception( raise Exception(
"Internal Server Error: Please check API key and parameters." "Internal Server Error: Please check API key and parameters."
) )
else: else:
raise Exception(f"Unexpected response: {response.status_code}") raise Exception(f"Unexpected response: {response.status_code}")
def example(): def example():
client = BricksetAPI(debug=True) client = BricksetAPI(debug=True)
try: try:
set_info = client.get_set("10931") set_info = client.get_set("10931")
print(set_info) print(set_info)
except Exception as e: except Exception as e:
print(f"Error: {e}") print(f"Error: {e}")
+113 -113
View File
@@ -1,113 +1,113 @@
import asyncio import asyncio
import aiohttp.web import aiohttp.web
import uuid import uuid
import os import os
from base64 import b64decode from base64 import b64decode
import dotenv import dotenv
dotenv.load_dotenv() dotenv.load_dotenv()
SQUARE_LEGO_CAT_ID = os.environ.get("SQUARE_LEGO_CAT_ID") SQUARE_LEGO_CAT_ID = os.environ.get("SQUARE_LEGO_CAT_ID")
from asyncio_throttle.throttler import Throttler from asyncio_throttle.throttler import Throttler
import asquare as sq import asquare as sq
import abricksetapi as alego import abricksetapi as alego
def valid_uuid(val): def valid_uuid(val):
try: try:
uuid.UUID(str(val)) uuid.UUID(str(val))
return True return True
except ValueError: except ValueError:
return False return False
async def fullsync(): async def fullsync():
"""' """'
full_db = await sq.acat2id(SQUARE_LEGO_CAT_ID) full_db = await sq.acat2id(SQUARE_LEGO_CAT_ID)
for l_set in full_db: for l_set in full_db:
l_setinfo = await alego.throttled_get_set(l_set) l_setinfo = await alego.throttled_get_set(l_set)
await sq.update_itm(l_set, l_setinfo)""" await sq.update_itm(l_set, l_setinfo)"""
async def stop(): async def stop():
pass pass
async def priceonly(): async def priceonly():
pass pass
async def handle_post(request): async def handle_post(request):
valid_cmd = ["RUNFULLSYNC", "STOP", "RUNPRICEONLY"] valid_cmd = ["RUNFULLSYNC", "STOP", "RUNPRICEONLY"]
async def handle_cmd(cmd: str): async def handle_cmd(cmd: str):
if cmd not in valid_cmd: if cmd not in valid_cmd:
return False return False
else: else:
return True return True
idp_key = request.headers.get("Idempotency-Key") idp_key = request.headers.get("Idempotency-Key")
user_agent = request.headers.get("User-Agent") user_agent = request.headers.get("User-Agent")
if not idp_key and not valid_uuid(idp_key) and user_agent != "Kopling/0.1": if not idp_key and not valid_uuid(idp_key) and user_agent != "Kopling/0.1":
# 412 Precondition Failed # 412 Precondition Failed
# The server does not meet one of the preconditions that the requester put on the request header fields. # The server does not meet one of the preconditions that the requester put on the request header fields.
return aiohttp.web.Response(status=412, text="412 Headers rejected!") return aiohttp.web.Response(status=412, text="412 Headers rejected!")
else: else:
data = await request.post() data = await request.post()
reqdata = data.get(b64decode("CMD")) reqdata = data.get(b64decode("CMD"))
if await handle_cmd(reqdata): if await handle_cmd(reqdata):
match reqdata: match reqdata:
case "RUNFULLSYNC": case "RUNFULLSYNC":
print("Running full sync...") print("Running full sync...")
await fullsync() await fullsync()
return True return True
case "STOP": case "STOP":
await stop() await stop()
return True return True
case "RUNPRICEONLY": case "RUNPRICEONLY":
await priceonly() await priceonly()
return True return True
else: else:
return aiohttp.web.Response(status=400, text="400 Bad Request.") return aiohttp.web.Response(status=400, text="400 Bad Request.")
result = {"message": f"studspider/0.1 received command: {reqdata}"} result = {"message": f"studspider/0.1 received command: {reqdata}"}
return aiohttp.web.json_response(status=200, data=result) return aiohttp.web.json_response(status=200, data=result)
app = aiohttp.web.Application() app = aiohttp.web.Application()
app.router.add_post("/", handle_post) app.router.add_post("/", handle_post)
import subprocess import subprocess
def generate_toilet_banner(text, font="smblock", filter_effect=None, colors=None): def generate_toilet_banner(text, font="smblock", filter_effect=None, colors=None):
command = ["toilet", "-f", font] command = ["toilet", "-f", font]
if filter_effect: if filter_effect:
command.extend(["-F", filter_effect]) command.extend(["-F", filter_effect])
if colors: if colors:
# Toilet uses a specific format for color arguments, e.g., --gay or --metal # Toilet uses a specific format for color arguments, e.g., --gay or --metal
# For general colors, you might need to use specific filters or direct color codes. # For general colors, you might need to use specific filters or direct color codes.
# This example focuses on filters that inherently use colors. # This example focuses on filters that inherently use colors.
pass # More complex color handling would be needed for arbitrary colors pass # More complex color handling would be needed for arbitrary colors
command.append(text) command.append(text)
try: try:
result = subprocess.run(command, capture_output=True, text=True, check=True) result = subprocess.run(command, capture_output=True, text=True, check=True)
return result.stdout return result.stdout
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
return f"Error executing toilet: {e}\n{e.stderr}" return f"Error executing toilet: {e}\n{e.stderr}"
async def main(): async def main():
throttler = Throttler(rate_limit=500, period=60) # 500 requests per minute throttler = Throttler(rate_limit=500, period=60) # 500 requests per minute
if __name__ == "__main__": if __name__ == "__main__":
print(generate_toilet_banner("studspider v0.1")) print(generate_toilet_banner("studspider v0.1"))
print("Built for PLAY-WELL TEKNOLOGIES by contact@traps.moe\n\n") print("Built for PLAY-WELL TEKNOLOGIES by contact@traps.moe\n\n")
aiohttp.web.run_app(app, port=7432) aiohttp.web.run_app(app, port=7432)
+165
View File
@@ -0,0 +1,165 @@
"""
Migrate SKU values to GTIN/UPC field for all LEGO items.
This script reads existing Square catalog items in the LEGO category,
and for each variation that has a SKU but no UPC, it moves the SKU
value to the UPC field (leaving SKU empty or optionally extracting
set number from item name).
"""
import asyncio
import json
import re
import sys
import uuid
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
async def migrate_sku_to_gtin():
"""Migrate SKU values to GTIN for all LEGO items."""
client = await get_square_client()
asquare = AsyncSquare()
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,
}
)
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"]
item_data = item.get("item_data", {})
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", {})
var_id = var.get("id")
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"]
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:
# Get fresh item data to ensure we have latest version
fresh_result = await client.catalog.batch_get(
object_ids=[item_id],
include_related_objects=False,
)
fresh_json = json.loads(fresh_result.model_dump_json())
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": {
"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}")
if __name__ == "__main__":
print("[@INFO] Starting SKU to GTIN migration...\n")
asyncio.run(migrate_sku_to_gtin())
+298
View File
@@ -0,0 +1,298 @@
"""
Statistics gathering functions for daily_stats table.
Queries Square catalog to collect metrics about LEGO inventory quality.
"""
import json
from typing import Dict, Any
from square import AsyncSquare
import get_sq_env
# Initialize Square client
client = AsyncSquare(
token=get_sq_env.SQUARE_TOKEN,
environment=get_sq_env.environment
)
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,
"enriched_items": int,
"missing_upcs": int,
"missing_images": int,
"total_catalog_value": float
}
"""
# Get all items in LEGO category
result = await client.catalog.search(
object_types=["ITEM"],
query={
"exact_query": {
"attribute_name": "category_id",
"attribute_value": LEGO_CAT_ID,
}
},
)
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"):
item_price = price_money["amount"] / 100.0 # Convert cents to dollars
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,
"missing_upcs": missing_upcs,
"missing_images": missing_images,
"total_catalog_value": round(total_catalog_value, 2),
}
async def get_total_items() -> int:
"""
Count total items in LEGO category.
Returns:
int: Total number of items
"""
result = await client.catalog.search(
object_types=["ITEM"],
query={
"exact_query": {
"attribute_name": "category_id",
"attribute_value": LEGO_CAT_ID,
}
},
)
result_json = json.loads(result.model_dump_json())
return len(result_json.get("objects", []))
async def get_enriched_count() -> int:
"""
Count items with complete enrichment (UPC + image + proper name format).
Returns:
int: Number of fully enriched items
"""
result = await client.catalog.search(
object_types=["ITEM"],
query={
"exact_query": {
"attribute_name": "category_id",
"attribute_value": LEGO_CAT_ID,
}
},
)
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
if " - " in name:
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
"""
result = await client.catalog.search(
object_types=["ITEM"],
query={
"exact_query": {
"attribute_name": "category_id",
"attribute_value": LEGO_CAT_ID,
}
},
)
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
"""
result = await client.catalog.search(
object_types=["ITEM"],
query={
"exact_query": {
"attribute_name": "category_id",
"attribute_value": LEGO_CAT_ID,
}
},
)
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
"""
result = await client.catalog.search(
object_types=["ITEM"],
query={
"exact_query": {
"attribute_name": "category_id",
"attribute_value": LEGO_CAT_ID,
}
},
)
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}%")
asyncio.run(main())
+16 -12
View File
@@ -1,33 +1,37 @@
import asyncio import asyncio
from asyncio_throttle import Throttler as AsyncThrottler from asyncio_throttle import Throttler as AsyncThrottler
class GlobalThrottler: class GlobalThrottler:
_instance = None _instance = None
_lock = asyncio.Lock() _lock = asyncio.Lock()
def __init__(self, rate_limit: int = 500, period: int = 60) -> None: def __init__(self, rate_limit: int = 500, period: int = 60) -> None:
if not hasattr(self, '_initialized'): if not hasattr(self, "_initialized"):
self.rate_limit = rate_limit self.rate_limit = rate_limit
self.period = period self.period = period
self.throttler = AsyncThrottler(rate_limit=self.rate_limit, period=self.period) self.throttler = AsyncThrottler(
rate_limit=self.rate_limit, period=self.period
)
self._initialized = True self._initialized = True
@classmethod @classmethod
async def get_instance(cls, rate_limit: int = 500, period: int = 60) -> 'GlobalThrottler': async def get_instance(
cls, rate_limit: int = 500, period: int = 60
) -> "GlobalThrottler":
async with cls._lock: async with cls._lock:
if not cls._instance: if not cls._instance:
cls._instance = cls(rate_limit, period) cls._instance = cls(rate_limit, period)
return cls._instance return cls._instance
async def acquire(self): async def acquire(self):
await self.throttler.acquire() await self.throttler.acquire()
def release(self):
self.throttler.release()
async def __aenter__(self): async def __aenter__(self):
await self.acquire() await self.acquire()
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
self.release() # asyncio_throttle.Throttler doesn't have a release() method
# It automatically manages rate limiting via acquire()
pass
+342 -11
View File
@@ -1,14 +1,345 @@
import src.test_server import os
import json
import asyncio
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
# Square webhook validation
from square.utils.webhooks_helper import verify_signature
# Configuration
SQUARE_WEBHOOK_SIGNATURE_KEY = sq_env.SQUARE_WEBHOOK_SIGNATURE_KEY
SQUARE_WEBHOOK_URL = sq_env.SQUARE_WEBHOOK_URL
SQUARE_LEGO_CAT_ID = sq_env.LEGO_CAT_ID
class Studspider: # Debug: Print configuration (redact key)
def __init__(self): if SQUARE_WEBHOOK_SIGNATURE_KEY:
self.name = "Studspider" print(f"[CONFIG] Signature key loaded: {SQUARE_WEBHOOK_SIGNATURE_KEY[:10]}...")
self.version = "0.0.1" else:
self.description = """ print("[CONFIG] WARNING: SQUARE_WEBHOOK_SIGNATURE_KEY not set!")
studspider is an application designed to interact with the Brickset API and directly with Square.
It allows users to search for items, manage collections, and perform various operations related to bricksets.
"""
def run(self): if SQUARE_WEBHOOK_URL:
print(f"{self.name} v{self.version} is running.") 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
# Create FastAPI app
app = FastAPI(title="Studspider")
@app.get("/")
async def root():
"""Landing page"""
return {
"service": "Studspider",
"status": "running",
"endpoints": {"webhook": "/hook (POST)", "health": "/health (GET)"},
}
@app.get("/health")
async def health_check():
"""Health check endpoint for monitoring"""
return {
"status": "healthy",
"signature_verification": (
"enabled" if SQUARE_WEBHOOK_SIGNATURE_KEY else "disabled"
),
}
@app.post("/hook")
async def webhook_handler(request: Request):
"""
Handle Square webhook events - MUST respond within 2 seconds!
Square sends POST requests with:
- x-square-hmacsha256-signature header
- JSON body with event data
- event_id for idempotency
"""
try:
# Log incoming request
print(f"\n[WEBHOOK] Incoming request from {request.client.host}")
print(f"[WEBHOOK] Method: {request.method}")
print(f"[WEBHOOK] URL: {request.url}")
print(f"[WEBHOOK] Headers: {dict(request.headers)}")
# Read raw body for signature verification
body = await request.body()
body_str = body.decode("utf-8")
print(f"[WEBHOOK] Body length: {len(body_str)} bytes")
# Get signature header
signature_header = request.headers.get("x-square-hmacsha256-signature", "")
# Verify Square webhook signature (if configured)
if SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_URL:
if not signature_header:
print(
f"[ERROR] Missing x-square-hmacsha256-signature header from {request.client.host}"
)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"error": "Missing signature header"},
)
# Debug signature verification inputs
print(f"[DEBUG] Signature header: {signature_header[:20]}...")
print(
f"[DEBUG] Signature key (first 10 chars): {SQUARE_WEBHOOK_SIGNATURE_KEY[:10]}..."
)
print(f"[DEBUG] Notification URL: {SQUARE_WEBHOOK_URL}")
print(f"[DEBUG] Body (first 100 chars): {body_str[:100]}...")
try:
is_valid = verify_signature(
request_body=body_str,
signature_header=signature_header,
signature_key=SQUARE_WEBHOOK_SIGNATURE_KEY,
notification_url=SQUARE_WEBHOOK_URL,
)
if not is_valid:
print(
f"[ERROR] Invalid webhook signature from {request.client.host}"
)
print(f"[ERROR] This usually means:")
print(
f"[ERROR] 1. SQUARE_WEBHOOK_URL doesn't match Square Dashboard exactly"
)
print(f"[ERROR] 2. SQUARE_WEBHOOK_SIGNATURE_KEY is incorrect")
print(f"[ERROR] 3. Request body was modified in transit")
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"error": "Invalid signature"},
)
print(f"[INFO] ✓ Webhook signature verified from {request.client.host}")
except Exception as e:
print(f"[ERROR] Signature verification exception: {e}")
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"error": f"Signature verification failed: {str(e)}"},
)
else:
print(
f"[WARN] Webhook signature verification DISABLED - configure SQUARE_WEBHOOK_SIGNATURE_KEY"
)
# Parse JSON data
try:
data = json.loads(body_str)
except json.JSONDecodeError as e:
print(f"[ERROR] Invalid JSON from {request.client.host}: {e}")
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"error": "Invalid JSON"},
)
# Check idempotency (event_id)
event_id = data.get("event_id")
if event_id:
if event_id in processed_event_ids:
print(
f"[INFO] Duplicate event {event_id} - already processed (idempotency)"
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={
"status": "success",
"message": "Event already processed",
"event_id": event_id,
},
)
# Add to processed set
processed_event_ids.add(event_id)
# Prevent memory growth
if len(processed_event_ids) > MAX_STORED_EVENT_IDS:
processed_event_ids.pop()
# Get event info for quick response
webhook_type = data.get("type", "unknown")
event_id_val = data.get("event_id", "none")
merchant_id = data.get("merchant_id", "unknown")
# RESPOND IMMEDIATELY - Square requires fast response (< 2 seconds)
response_data = {
"status": "success",
"message": "Webhook received",
"event_id": event_id_val,
}
# Process webhook in background (async task)
asyncio.create_task(
process_webhook(
webhook_type, event_id_val, merchant_id, data, request.client.host
)
)
# Return immediate response
print(f"[INFO] 200 OK response sent for event {event_id_val}")
return JSONResponse(status_code=status.HTTP_200_OK, content=response_data)
except Exception as e:
# Catch-all for any unexpected errors - still respond quickly
print(f"[ERROR] Unexpected error in webhook handler: {e}")
import traceback
traceback.print_exc()
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"error": "Internal server error"},
)
# Return immediate response
return JSONResponse(status_code=status.HTTP_200_OK, content=response_data)
async def process_webhook(
webhook_type: str,
event_id: str,
merchant_id: str,
data: Dict[str, Any],
client_ip: str,
):
"""
Process webhook data asynchronously after responding to Square
This runs in the background and doesn't block the HTTP response
"""
try:
print("\n" + "=" * 60)
print(f"[WEBHOOK] Received at {asyncio.get_event_loop().time()}")
print(f"[WEBHOOK] From: {client_ip}")
print(f"[WEBHOOK] Type: {webhook_type}")
print(f"[WEBHOOK] Event ID: {event_id}")
print(f"[WEBHOOK] Merchant: {merchant_id}")
print(f"[WEBHOOK] Full payload:")
print(json.dumps(data, indent=2))
print("=" * 60 + "\n")
# Process based on webhook type
if webhook_type == "inventory.count.updated":
print(f"[PROCESS] Processing inventory count update...")
inventory_counts = (
data.get("data", {}).get("object", {}).get("inventory_counts", [])
)
# Import here to avoid circular imports
import src.asquare as asquare
import src.glue as glue
for count in inventory_counts:
variation_id = count.get("catalog_object_id")
quantity = count.get("quantity")
state = count.get("state")
print(
f"[PROCESS] Catalog: {variation_id}, Qty: {quantity}, State: {state}"
)
try:
# Get parent item from variation ID
item = await asquare.get_item_by_variation_id(variation_id)
item_id = item.get("id")
item_name = item.get("name", "")
category_id = item.get("category_id")
print(f"[PROCESS] Item: {item_id} - {item_name}")
print(f"[PROCESS] Category: {category_id}")
# Only process LEGO category items
if category_id == SQUARE_LEGO_CAT_ID:
print(
f"[PROCESS] ✓ LEGO item detected - triggering enrichment"
)
# Trigger enrichment in background with item data to avoid redundant lookup
asyncio.create_task(
glue.enrich_single_item(item_id, item_data=item)
)
else:
print(f"[PROCESS] ✗ Not a LEGO item - skipping enrichment")
except Exception as e:
print(f"[ERROR] Failed to process variation {variation_id}: {e}")
elif webhook_type == "catalog.version.updated":
print(f"[PROCESS] Processing catalog version update...")
# Import here to avoid circular imports
import src.asquare as asquare
import src.glue as glue
catalog_object = data.get("data", {}).get("object", {})
object_type = catalog_object.get("type")
if object_type == "ITEM":
item_id = catalog_object.get("id")
print(f"[PROCESS] Item updated: {item_id}")
try:
# Get item details to check category
item = await asquare.get_item_by_id(item_id)
item_name = item.get("name", "")
category_id = item.get("category_id")
print(f"[PROCESS] Item: {item_id} - {item_name}")
print(f"[PROCESS] Category: {category_id}")
# Only process LEGO category items
if category_id == SQUARE_LEGO_CAT_ID:
print(
f"[PROCESS] ✓ LEGO item detected - triggering enrichment"
)
# Trigger enrichment in background with item data to avoid redundant lookup
asyncio.create_task(
glue.enrich_single_item(item_id, item_data=item)
)
else:
print(f"[PROCESS] ✗ Not a LEGO item - skipping enrichment")
except Exception as e:
print(f"[ERROR] Failed to process item {item_id}: {e}")
else:
print(f"[PROCESS] Object type '{object_type}' - skipping")
else:
print(f"[PROCESS] Webhook type '{webhook_type}' - no handler configured")
print(f"[PROCESS] ✓ Webhook processing complete for {event_id}")
except Exception as e:
print(f"[ERROR] Failed to process webhook {event_id}: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
# For development/testing - use uvicorn directly
import uvicorn
print("\n" + "=" * 60)
print("Studspider webhook boot dev")
print("=" * 60)
print(
f"Signature verification: {'ENABLED' if SQUARE_WEBHOOK_SIGNATURE_KEY else 'DISABLED'}"
)
print(f"Webhook URL: {SQUARE_WEBHOOK_URL or 'NOT CONFIGURED'}")
print("\nFor production, use Gunicorn:")
print(
" gunicorn conf/studspider:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:7433"
)
print("=" * 60 + "\n")
uvicorn.run(app, host="0.0.0.0", port=7433, log_level="info")
+39 -39
View File
@@ -1,39 +1,39 @@
import asyncio import asyncio
import aiohttp import aiohttp
from uuid import uuid4 from uuid import uuid4
from base64 import b64encode from base64 import b64encode
import os import os
async def idp_request(cmd: str): async def idp_request(cmd: str):
headers = { headers = {
"Idempotency-Key": f"{uuid4()}", "Idempotency-Key": f"{uuid4()}",
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": "Kopling/0.12", "User-Agent": "Kopling/0.12",
} }
print(f"[@DEBUG] Idempotency-Key: {headers['Idempotency-Key']}") print(f"[@DEBUG] Idempotency-Key: {headers['Idempotency-Key']}")
enc_cmd = b64encode(f"{cmd}".encode("utf-8")).decode("utf-8") enc_cmd = b64encode(f"{cmd}".encode("utf-8")).decode("utf-8")
print("[@DEBUG] Encoded command:", enc_cmd) print("[@DEBUG] Encoded command:", enc_cmd)
print("[@DEBUG] Sending request..") print("[@DEBUG] Sending request..")
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post( async with session.post(
"http://localhost:7432", # Added http:// protocol "http://localhost:7432", # Added http:// protocol
headers=headers, headers=headers,
json={"CMD": enc_cmd}, # Changed to json instead of data json={"CMD": enc_cmd}, # Changed to json instead of data
) as resp: ) as resp:
print(f"[@DEBUG] Response status: {resp.status}") print(f"[@DEBUG] Response status: {resp.status}")
print(f"[@DEBUG] Response body: {await resp.text()}") print(f"[@DEBUG] Response body: {await resp.text()}")
except Exception as e: except Exception as e:
print(f"\nRequest failed: {e}") print(f"\nRequest failed: {e}")
os._exit(-1) os._exit(-1)
async def main(): async def main():
# Await the task instead of just creating it # Await the task instead of just creating it
await idp_request("RUNUPDATE") await idp_request("RUNUPDATE")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
+438 -400
View File
@@ -1,400 +1,438 @@
import argparse import argparse
import asyncio import asyncio
import json import json
import ssl import ssl
from aiohttp import web from aiohttp import web
from base64 import b64decode from base64 import b64decode
from typing import Dict, Any from typing import Dict, Any
import uuid import uuid
import dotenv import dotenv
import os import os
import re import re
import logging import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
import banner import banner
import get_sq_env import get_sq_env
from time import time from time import time
time = int(time()) time = int(time())
import asquare as sq import asquare as sq
# from asquareclass import AsyncSquareClient # type: ignore # Square SDK webhook validation
from abricksetapi import AsyncBricksetAPI from square.utils.webhooks_helper import verify_signature
print("Loading environment variables...") # from asquareclass import AsyncSquareClient # type: ignore
dotenv.load_dotenv() from abricksetapi import AsyncBricksetAPI
SQUARE_LEGO_CAT_ID = os.environ.get("SQUARE_LEGO_CAT_ID")
print("Loading environment variables...")
from asyncio_throttle.throttler import Throttler dotenv.load_dotenv()
SQUARE_LEGO_CAT_ID = os.environ.get("SQUARE_LEGO_CAT_ID")
# set up logger SQUARE_WEBHOOK_SIGNATURE_KEY = os.environ.get("SQUARE_WEBHOOK_SIGNATURE_KEY")
print("Setting up logger...") SQUARE_WEBHOOK_URL = os.environ.get("SQUARE_WEBHOOK_URL")
logger = logging.getLogger("studspider")
logger.setLevel(logging.DEBUG) from asyncio_throttle.throttler import Throttler
handler = RotatingFileHandler("./logs/studspider.log", maxBytes=125000, backupCount=5)
logger.addHandler(handler) # set up logger
print("Setting up logger...")
lego = AsyncBricksetAPI(debug=True, logger=logger) logger = logging.getLogger("studspider")
# sq = AsyncSquareClient(logger=logger) logger.setLevel(logging.DEBUG)
# logging.basicConfig( handler = RotatingFileHandler("./logs/studspider.log", maxBytes=125000, backupCount=5)
# filename="./logs/studspider.log", encoding="utf-8", level=logging.DEBUG logger.addHandler(handler)
# )
print("Setting up rate limiter...") lego = AsyncBricksetAPI(debug=True, logger=logger)
print("Doing RE things...") # sq = AsyncSquareClient(logger=logger)
UA = re.compile(r"^kopling\/?", re.IGNORECASE) # logging.basicConfig(
# filename="./logs/studspider.log", encoding="utf-8", level=logging.DEBUG
# )
async def run_update(): print("Setting up rate limiter...")
set_list = await sq.acat2id(SQUARE_LEGO_CAT_ID) # type: ignore print("Doing RE things...")
# print(f"[@DEBUG]\t {set_list}") # type: ignore UA = re.compile(r"^kopling\/?", re.IGNORECASE)
for l_set in set_list:
# print(f"[@DEBUG]{l_set}")
try: async def run_update():
l_setinfo = await lego.throttled_get_set(l_set["number"]) """
print(f"[@DEBUG] Found {l_setinfo["pw_name"]}") DEPRECATED: Use glue.enrich_bulk() instead
except Exception as e:
print(f"Error fetching set\t: {l_set}: {str(e)}") Legacy RUNUPDATE command handler - kept for backwards compatibility
continue but now delegates to the new glue.enrich_bulk() function
"""
# print(f"[@DEBUG]{l_setinfo}") from src import glue
# await sq.update_itm(l_set, l_setinfo)
"""for l_set in set_list: await glue.enrich_bulk()
print(f"[@DEBUG]{l_set}")
try:
l_setinfo = await lego.throttled_get_set(l_set["number"]) def valid_uuid(val):
except Exception as e: try:
print(f"Error fetching set\t: {l_set}: {str(e)}") uuid.UUID(str(val))
continue return True
except ValueError:
print(f"[@DEBUG]{l_setinfo}")""" return False
# await sq.update_itm(l_set, l_setinfo)
# In-memory store for processed event IDs (idempotency)
def valid_uuid(val): processed_event_ids = set()
try: MAX_STORED_EVENT_IDS = 10000 # Prevent memory growth
uuid.UUID(str(val))
return True
except ValueError: async def hook_handler(request: web.Request) -> web.Response:
return False """Handle incoming JSON webhook POSTs on /hook endpoint"""
try:
current_time = int(asyncio.get_event_loop().time())
async def hook_handler(request: web.Request) -> web.Response:
"""Handle incoming JSON webhook POSTs on /hook endpoint""" # Verify request method
try: if request.method != "POST":
current_time = int(asyncio.get_event_loop().time()) logger.error(
f"{current_time}\tInvalid request method to /hook from %s",
# Verify request method request.remote,
if request.method != "POST": )
logger.error( return web.Response(status=405, text="Method not allowed. Use POST.")
f"{current_time}\tInvalid request method to /hook from %s",
request.remote, # Read raw body for signature verification
) body = await request.read()
return web.Response(status=405, text="Method not allowed. Use POST.") body_str = body.decode("utf-8")
# Get JSON data from request # Verify Square webhook signature using Square SDK
try: signature_header = request.headers.get("x-square-hmacsha256-signature", "")
data = await request.json()
except json.JSONDecodeError as e: if SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_URL:
print(f"[@DEBUG] Invalid JSON to /hook from {request.remote}: {str(e)}") if not signature_header:
logger.error(f"Invalid JSON to /hook from {request.remote}: {str(e)}") logger.error(
return web.Response(status=400, text="Invalid JSON in request body") f"{current_time}\tMissing signature header from {request.remote}"
)
# Log the received webhook with detailed output print(f"[@DEBUG] Missing x-square-hmacsha256-signature header")
print(f"\n{'='*60}") return web.Response(status=401, text="Unauthorized: Missing signature")
print(f"[@DEBUG] WEBHOOK RECEIVED at {current_time}")
print(f"[@DEBUG] From: {request.remote}") # Use Square SDK's official validation function
print(f"[@DEBUG] Headers: {dict(request.headers)}") is_valid = verify_signature(
print(f"[@DEBUG] Data type: {data.get('type', 'unknown')}") request_body=body_str,
print(f"[@DEBUG] Event ID: {data.get('event_id', 'none')}") signature_header=signature_header,
print(f"[@DEBUG] Full payload:") signature_key=SQUARE_WEBHOOK_SIGNATURE_KEY,
print(json.dumps(data, indent=2)) notification_url=SQUARE_WEBHOOK_URL,
print(f"{'='*60}\n") )
logger.info( if not is_valid:
f"{current_time}\tWebhook received from {request.remote}: {json.dumps(data)}" logger.error(f"{current_time}\tInvalid signature from {request.remote}")
) print(f"[@DEBUG] Invalid webhook signature!")
return web.Response(status=401, text="Unauthorized: Invalid signature")
# Process the webhook data here
# TODO: Add your webhook processing logic print(f"[@DEBUG] ✓ Webhook signature verified")
webhook_type = data.get("type", "unknown") else:
print(
if webhook_type == "inventory.count.updated": f"[@DEBUG] ⚠️ Webhook signature verification DISABLED (set SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_URL in .env)"
print(f"[@DEBUG] Processing inventory count update...") )
# Extract inventory data
inventory_counts = ( # Parse JSON data
data.get("data", {}).get("object", {}).get("inventory_counts", []) try:
) data = json.loads(body_str)
for count in inventory_counts: except json.JSONDecodeError as e:
print(f"[@DEBUG] Catalog Object: {count.get('catalog_object_id')}") print(f"[@DEBUG] Invalid JSON to /hook from {request.remote}: {str(e)}")
print(f"[@DEBUG] Quantity: {count.get('quantity')}") logger.error(f"Invalid JSON to /hook from {request.remote}: {str(e)}")
print(f"[@DEBUG] State: {count.get('state')}") return web.Response(status=400, text="Invalid JSON in request body")
response_data = { # Check for idempotency (event_id)
"status": "success", event_id = data.get("event_id")
"message": f"Webhook received: {webhook_type}", if event_id:
"timestamp": current_time, if event_id in processed_event_ids:
"event_id": data.get("event_id"), print(
"data_received": data, f"[@DEBUG] Duplicate event {event_id} - already processed (idempotency)"
} )
logger.info(
return web.json_response(response_data) f"{current_time}\tDuplicate event {event_id} from {request.remote}"
)
except json.JSONDecodeError as e: return web.json_response(
error_msg = f"JSON decode error in webhook: {str(e)}" {
print(f"[@DEBUG] {error_msg}") "status": "success",
logger.error(error_msg) "message": "Event already processed (idempotency)",
return web.Response(status=400, text=error_msg) "event_id": event_id,
except Exception as e: }
error_msg = f"Webhook handler error: {str(e)}" )
print(f"[@DEBUG] {error_msg}")
print(f"[@DEBUG] Exception type: {type(e).__name__}") # Add to processed set
import traceback processed_event_ids.add(event_id)
print(f"[@DEBUG] Traceback:\n{traceback.format_exc()}") # Prevent memory growth - remove oldest if too many
logger.error(f"{error_msg}\n{traceback.format_exc()}") if len(processed_event_ids) > MAX_STORED_EVENT_IDS:
return web.Response(status=500, text="Internal server error") processed_event_ids.pop()
# Get event info for quick response
async def request_handler(request: web.Request) -> web.Response: webhook_type = data.get("type", "unknown")
"""Handle incoming POST requests with command processing""" event_id_val = data.get("event_id", "none")
try:
# Verify request method # RESPOND IMMEDIATELY - Square requires fast response (< 2 seconds)
if request.method != "POST": # Don't do any processing before responding!
logger.error(f"{time}\tInvalid request method from %s at ", request.remote) response_data = {
return web.Response(status=405, text="Method not allowed. Use POST.") "status": "success",
"message": "Webhook received",
# Get JSON data from request "event_id": event_id_val,
try: }
data = await request.json()
except json.JSONDecodeError: # Log and process AFTER sending response (async background tasks)
logger.error("Invalid JSON from %s", request.remote) async def log_and_process():
return web.Response( # Log the received webhook with detailed output
status=400, text="{time()}\tInvalid JSON in request body" print(f"\n{'='*60}")
) print(f"[@DEBUG] WEBHOOK RECEIVED at {current_time}")
# Validate Idempotency-Key header print(f"[@DEBUG] From: {request.remote}")
if valid_uuid(request.headers.get("Idempotency-Key")) is False: print(f"[@DEBUG] Headers: {dict(request.headers)}")
logger.error("{time()}\tInvalid Idempotency-Key from %s", request.remote) print(f"[@DEBUG] Data type: {webhook_type}")
return web.Response(status=412, text="412 Headers rejected!") print(f"[@DEBUG] Event ID: {event_id_val}")
# Validate User-Agent header print(f"[@DEBUG] Full payload:")
if not re.match(UA, str(request.headers.get("User-Agent"))): print(json.dumps(data, indent=2))
logger.error("{time()}\tInvalid User-Agent from %s", request.remote) print(f"{'='*60}\n")
return web.Response(status=412, text="412 Headers rejected!")
# Validate command exists logger.info(
if "CMD" not in data: f"{current_time}\tWebhook received from {request.remote}: {json.dumps(data)}"
logger.error("{time()}\tMissing CMD field from %s", request.remote) )
return web.Response(status=400, text="Missing CMD field in request")
# Process the webhook data
# Decode and process command if webhook_type == "inventory.count.updated":
try: print(f"[@DEBUG] Processing inventory count update...")
decoded_cmd = b64decode(data["CMD"]).decode("utf-8") # Extract inventory data
print( inventory_counts = (
f"""At: {time}\nReceived command: {decoded_cmd}\nUser-Agent: {request.headers.get('User-Agent')}\nIdempotency-Key: {request.headers.get('Idempotency-Key')}""" data.get("data", {}).get("object", {}).get("inventory_counts", [])
) )
logging.info( for count in inventory_counts:
f"{time}\tCommand: {decoded_cmd}\tFrom: {request.remote}\tUA: {request.headers.get('User-Agent')}\tIdempotency-Key: {request.headers.get('Idempotency-Key')}" print(
) f"[@DEBUG] Catalog Object: {count.get('catalog_object_id')}"
)
# Process the command print(f"[@DEBUG] Quantity: {count.get('quantity')}")
if decoded_cmd == "RUNUPDATE": print(f"[@DEBUG] State: {count.get('state')}")
response_data = {"status": "success", "message": "Update started"}
await run_update() # Start background processing (don't wait for it)
logging.info(f"{time}\tUpdate command processed successfully") asyncio.create_task(log_and_process())
return web.json_response(response_data)
else: # Return response immediately
return web.Response(status=400, text=f"Unknown command: {decoded_cmd}") return web.json_response(response_data)
except Exception as e: except json.JSONDecodeError as e:
print(f"Error processing command: {str(e)}") error_msg = f"JSON decode error in webhook: {str(e)}"
return web.Response(status=500, text=f"Server error: {str(e)}") print(f"[@DEBUG] {error_msg}")
logger.error(error_msg)
except Exception as e: return web.Response(status=400, text=error_msg)
print(f"Request handler error: {str(e)}") except Exception as e:
return web.Response(status=500, text="Internal server error") error_msg = f"Webhook handler error: {str(e)}"
print(f"[@DEBUG] {error_msg}")
print(f"[@DEBUG] Exception type: {type(e).__name__}")
print("Starting web server...") import traceback
os.system("cls" if os.name == "nt" else "clear")
app = web.Application() print(f"[@DEBUG] Traceback:\n{traceback.format_exc()}")
app.router.add_post("/api", request_handler) logger.error(f"{error_msg}\n{traceback.format_exc()}")
app.router.add_post("/hook", hook_handler) return web.Response(status=500, text="Internal server error")
parser = argparse.ArgumentParser(
description="studspider - LEGO Inventory Sync Service for Square POS and Brickset", async def request_handler(request: web.Request) -> web.Response:
epilog=""" """Handle incoming POST requests with command processing"""
Examples: try:
# Basic HTTP server (recommended with nginx reverse proxy) # Verify request method
python src/test_server.py --host 0.0.0.0 --port 7432 if request.method != "POST":
logger.error(f"{time}\tInvalid request method from %s at ", request.remote)
# With separate webhook port return web.Response(status=405, text="Method not allowed. Use POST.")
python src/test_server.py --port 7432 --hook-port 8080
# Get JSON data from request
# HTTPS using certificates from .env (set SSL_CERT_PATH and SSL_KEY_PATH) try:
python src/test_server.py --hook-port 8443 --hook-ssl-only data = await request.json()
except json.JSONDecodeError:
# HTTPS with command line override (self-signed cert for testing) logger.error("Invalid JSON from %s", request.remote)
python src/test_server.py --ssl-cert cert.pem --ssl-key key.pem --hook-port 8443 --hook-ssl-only return web.Response(
status=400, text="{time()}\tInvalid JSON in request body"
# Full HTTPS (both endpoints) )
python src/test_server.py --ssl-cert cert.pem --ssl-key key.pem --hook-port 8443 # Validate Idempotency-Key header
if valid_uuid(request.headers.get("Idempotency-Key")) is False:
# Production behind nginx (no SSL needed in app) logger.error("{time()}\tInvalid Idempotency-Key from %s", request.remote)
python src/test_server.py --host 127.0.0.1 --port 7432 return web.Response(status=412, text="412 Headers rejected!")
# Validate User-Agent header
Environment Variables (.env): if not re.match(UA, str(request.headers.get("User-Agent"))):
SSL_CERT_PATH - Default SSL certificate path logger.error("{time()}\tInvalid User-Agent from %s", request.remote)
SSL_KEY_PATH - Default SSL key path return web.Response(status=412, text="412 Headers rejected!")
SQUARE_P7B_PATH - P7B file to upload to Square Dashboard # Validate command exists
if "CMD" not in data:
For more information, see doc/ logger.error("{time()}\tMissing CMD field from %s", request.remote)
""", return web.Response(status=400, text="Missing CMD field in request")
formatter_class=argparse.RawDescriptionHelpFormatter,
) # Decode and process command
parser.add_argument( try:
"--host", decoded_cmd = b64decode(data["CMD"]).decode("utf-8")
type=str, print(
default="localhost", f"""At: {time}\nReceived command: {decoded_cmd}\nUser-Agent: {request.headers.get('User-Agent')}\nIdempotency-Key: {request.headers.get('Idempotency-Key')}"""
help="IP address to listen on (default: localhost, use 0.0.0.0 for all interfaces)", )
) logging.info(
parser.add_argument( f"{time}\tCommand: {decoded_cmd}\tFrom: {request.remote}\tUA: {request.headers.get('User-Agent')}\tIdempotency-Key: {request.headers.get('Idempotency-Key')}"
"--port", type=int, default=7432, help="Port for main API endpoint (default: 7432)" )
)
parser.add_argument( # Process the command
"--hook-port", if decoded_cmd == "RUNUPDATE":
type=int, response_data = {"status": "success", "message": "Update started"}
default=None, await run_update()
help="Separate port for webhook endpoint (default: None - uses same port as --port)", logging.info(f"{time}\tUpdate command processed successfully")
) return web.json_response(response_data)
parser.add_argument( else:
"--ssl-cert", return web.Response(status=400, text=f"Unknown command: {decoded_cmd}")
type=str,
default=get_sq_env.SSL_CERT_PATH, except Exception as e:
help="Path to SSL certificate file in PEM format (default: from .env SSL_CERT_PATH)", print(f"Error processing command: {str(e)}")
) return web.Response(status=500, text=f"Server error: {str(e)}")
parser.add_argument(
"--ssl-key", except Exception as e:
type=str, print(f"Request handler error: {str(e)}")
default=get_sq_env.SSL_KEY_PATH, return web.Response(status=500, text="Internal server error")
help="Path to SSL private key file in PEM format (default: from .env SSL_KEY_PATH)",
)
parser.add_argument( print("Starting web server...")
"--hook-ssl-only", os.system("cls" if os.name == "nt" else "clear")
action="store_true", app = web.Application()
help="Enable HTTPS only for webhook endpoint, main API stays HTTP (requires --ssl-cert and --ssl-key)", app.router.add_post("/api", request_handler)
) app.router.add_post("/hook", hook_handler)
args = parser.parse_args()
parser = argparse.ArgumentParser(
if __name__ == "__main__": description="studspider - LEGO Inventory Sync Service for Square POS and Brickset",
print("Creating log directory if it doesn't exist...") epilog="""
os.makedirs("./logs/", exist_ok=True) Examples:
print(banner.generate("studspider v0.1")) # Basic HTTP server (recommended with nginx reverse proxy)
print("Built for PLAY-WELL TEKNOLOGIES by contact@studspider.sh\n\n") python src/test_server.py --host 0.0.0.0 --port 7432
# Setup SSL context if certificates provided # With separate webhook port
ssl_context = None python src/test_server.py --port 7432 --hook-port 8080
if args.ssl_cert and args.ssl_key:
# Check if trying to use P7B file # HTTPS using certificates from .env (set SSL_CERT_PATH and SSL_KEY_PATH)
if args.ssl_cert.endswith(".p7b") or args.ssl_key.endswith(".p7b"): python src/test_server.py --hook-port 8443 --hook-ssl-only
print("ERROR: Cannot use P7B files directly for HTTPS server!")
print("\nP7B files are for Square's webhook signature verification only.") # HTTPS with command line override (self-signed cert for testing)
print("Your server needs PEM format files (.pem)") python src/test_server.py --ssl-cert cert.pem --ssl-key key.pem --hook-port 8443 --hook-ssl-only
print("\nWorkflow:")
print("1. Generate PEM files: python generate_cert.py") # Full HTTPS (both endpoints)
print("2. Convert to P7B for Square: python convert_to_p7b.py") python src/test_server.py --ssl-cert cert.pem --ssl-key key.pem --hook-port 8443
print("3. Upload P7B to Square Dashboard")
print("4. Run server with PEM files:") # Production behind nginx (no SSL needed in app)
print( python src/test_server.py --host 127.0.0.1 --port 7432
" python src/test_server.py --ssl-cert cert.pem --ssl-key key.pem --hook-port 8443"
) Environment Variables (.env):
print("\nIf you have a P7B and need PEM:") SSL_CERT_PATH - Default SSL certificate path
print(" python convert_from_p7b.py your_cert.p7b") SSL_KEY_PATH - Default SSL key path
exit(1)
For more information, see doc/
try: """,
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) formatter_class=argparse.RawDescriptionHelpFormatter,
ssl_context.load_cert_chain(args.ssl_cert, args.ssl_key) )
print(f"✓ SSL enabled with cert: {args.ssl_cert}") parser.add_argument(
"--host",
# Show P7B info if configured type=str,
if get_sq_env.SQUARE_P7B_PATH: default="localhost",
print( help="IP address to listen on (default: localhost, use 0.0.0.0 for all interfaces)",
f"ℹ️ Square P7B certificate for upload: {get_sq_env.SQUARE_P7B_PATH}" )
) parser.add_argument(
if os.path.exists(get_sq_env.SQUARE_P7B_PATH): "--port", type=int, default=7432, help="Port for main API endpoint (default: 7432)"
print(f" ✓ P7B file exists") )
else: parser.add_argument(
print(f" ⚠️ P7B file not found - run: python convert_to_p7b.py") "--hook-port",
except FileNotFoundError as e: type=int,
print(f"ERROR: Certificate file not found: {e}") default=None,
exit(1) help="Separate port for webhook endpoint (default: None - uses same port as --port)",
except ssl.SSLError as e: )
print(f"ERROR: Invalid certificate or key file: {e}") parser.add_argument(
print("\nMake sure you're using PEM format files.") "--ssl-cert",
print("Generate certificates with: python generate_cert.py") type=str,
exit(1) default=get_sq_env.SSL_CERT_PATH,
elif args.ssl_cert or args.ssl_key: help="Path to SSL certificate file in PEM format (default: from .env SSL_CERT_PATH)",
print("ERROR: Both --ssl-cert and --ssl-key must be provided together") )
exit(1) parser.add_argument(
else: "--ssl-key",
print("ℹ️ Running without SSL (HTTP only)") type=str,
print(" For production with nginx reverse proxy, this is recommended.") default=get_sq_env.SSL_KEY_PATH,
print(" Nginx will handle HTTPS, forward HTTP to this server.") help="Path to SSL private key file in PEM format (default: from .env SSL_KEY_PATH)",
)
# Show P7B info even without SSL (for nginx setup) parser.add_argument(
if get_sq_env.SQUARE_P7B_PATH: "--hook-ssl-only",
print( action="store_true",
f"\nℹ️ Square P7B certificate configured: {get_sq_env.SQUARE_P7B_PATH}" help="Enable HTTPS only for webhook endpoint, main API stays HTTP (requires --ssl-cert and --ssl-key)",
) )
if os.path.exists(get_sq_env.SQUARE_P7B_PATH): args = parser.parse_args()
print(f" ✓ P7B file exists - upload this to Square Dashboard")
print(f" Square Dashboard → Webhooks → Manage Notification Settings") if __name__ == "__main__":
else: print("Creating log directory if it doesn't exist...")
print(f" ⚠️ P7B file not found") os.makedirs("./logs/", exist_ok=True)
print( print(banner.generate("studspider v0.1"))
f" Generate with: python convert_to_p7b.py --cert /path/to/nginx/cert.pem" print("Built for PLAY-WELL TEKNOLOGIES by contact@studspider.sh\n\n")
)
# Setup SSL context if certificates provided
# If hook-port is specified, run a separate server for webhooks ssl_context = None
if args.hook_port: if args.ssl_cert and args.ssl_key:
protocol_main = "https" if (ssl_context and not args.hook_ssl_only) else "http" # Check if trying to use P7B file
protocol_hook = "https" if ssl_context else "http" if args.ssl_cert.endswith(".p7b") or args.ssl_key.endswith(".p7b"):
print("ERROR: Cannot use P7B files directly for HTTPS server!")
# Create separate app for webhook print("\nP7B files are for Square's webhook signature verification only.")
hook_app = web.Application() print("Your server needs PEM format files (.pem)")
hook_app.router.add_post("/hook", hook_handler) print("\nWorkflow:")
print("1. Generate PEM files: python generate_cert.py")
# Run both servers concurrently print("2. Convert to P7B for Square: python convert_to_p7b.py")
async def run_servers(): print("3. Upload P7B to Square Dashboard")
runner1 = web.AppRunner(app) print("4. Run server with PEM files:")
await runner1.setup() print(
# Main server uses SSL only if not hook-ssl-only mode " python src/test_server.py --ssl-cert cert.pem --ssl-key key.pem --hook-port 8443"
main_ssl = None if args.hook_ssl_only else ssl_context )
site1 = web.TCPSite(runner1, args.host, args.port, ssl_context=main_ssl) print("\nIf you have a P7B and need PEM:")
print(" python convert_from_p7b.py your_cert.p7b")
runner2 = web.AppRunner(hook_app) exit(1)
await runner2.setup()
# Webhook server always uses SSL if certificates provided try:
site2 = web.TCPSite( ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
runner2, args.host, args.hook_port, ssl_context=ssl_context ssl_context.load_cert_chain(args.ssl_cert, args.ssl_key)
) print(f"✓ SSL enabled with cert: {args.ssl_cert}")
except FileNotFoundError as e:
await site1.start() print(f"ERROR: Certificate file not found: {e}")
await site2.start() exit(1)
except ssl.SSLError as e:
print(f"Starting main server on {args.host}:{args.port} ({protocol_main})") print(f"ERROR: Invalid certificate or key file: {e}")
print( print("\nMake sure you're using PEM format files.")
f"Starting webhook server on {args.host}:{args.hook_port} ({protocol_hook})" exit(1)
) elif args.ssl_cert or args.ssl_key:
print("ERROR: Both --ssl-cert and --ssl-key must be provided together")
# Keep running exit(1)
await asyncio.Event().wait() else:
print("ℹ️ Running without SSL (HTTP only)")
asyncio.run(run_servers()) print(" For production with nginx reverse proxy, this is recommended.")
else: print(" Nginx will handle HTTPS, forward HTTP to this server.")
protocol = "https" if ssl_context else "http"
print(f"Server running on {args.host}:{args.port} ({protocol})") # If hook-port is specified, run a separate server for webhooks
print(f"Webhook endpoint: {protocol}://{args.host}:{args.port}/hook") if args.hook_port:
web.run_app(app, host=args.host, port=args.port, ssl_context=ssl_context) protocol_main = "https" if (ssl_context and not args.hook_ssl_only) else "http"
protocol_hook = "https" if ssl_context else "http"
# Create separate app for webhook
hook_app = web.Application()
hook_app.router.add_post("/hook", hook_handler)
# Run both servers concurrently
async def run_servers():
runner1 = web.AppRunner(app)
await runner1.setup()
# Main server uses SSL only if not hook-ssl-only mode
main_ssl = None if args.hook_ssl_only else ssl_context
site1 = web.TCPSite(runner1, args.host, args.port, ssl_context=main_ssl)
runner2 = web.AppRunner(hook_app)
await runner2.setup()
# Webhook server always uses SSL if certificates provided
site2 = web.TCPSite(
runner2, args.host, args.hook_port, ssl_context=ssl_context
)
await site1.start()
await site2.start()
print(f"Starting main server on {args.host}:{args.port} ({protocol_main})")
print(
f"Starting webhook server on {args.host}:{args.hook_port} ({protocol_hook})"
)
# Keep running
await asyncio.Event().wait()
asyncio.run(run_servers())
else:
protocol = "https" if ssl_context else "http"
print(f"Server running on {args.host}:{args.port} ({protocol})")
print(f"Webhook endpoint: {protocol}://{args.host}:{args.port}/hook")
web.run_app(app, host=args.host, port=args.port, ssl_context=ssl_context)
+51
View File
@@ -0,0 +1,51 @@
"""
Simple test script to verify Square webhook signature verification works correctly.
This uses the same test data from Square's documentation to ensure our implementation
matches their expected behavior.
"""
import sys
sys.path.insert(0, "src")
from square.utils.webhooks_helper import verify_signature
# Test data from Square's documentation
TEST_BODY = '{"hello":"world"}'
TEST_SIGNATURE = "2kRE5qRU2tR+tBGlDwMEw2avJ7QM4ikPYD/PJ3bd9Og="
TEST_SIGNATURE_KEY = "asdf1234"
TEST_NOTIFICATION_URL = "https://example.com/webhook"
def test_signature_validation():
"""Test that signature validation works with Square SDK"""
print("Testing Square webhook signature validation...")
print(f"Body: {TEST_BODY}")
print(f"Signature: {TEST_SIGNATURE}")
print(f"Key: {TEST_SIGNATURE_KEY}")
print(f"URL: {TEST_NOTIFICATION_URL}")
print()
# This should return True with Square's test data
is_valid = verify_signature(
request_body=TEST_BODY,
signature_header=TEST_SIGNATURE,
signature_key=TEST_SIGNATURE_KEY,
notification_url=TEST_NOTIFICATION_URL,
)
if is_valid:
print("✅ SUCCESS: Signature validation passed!")
print("The webhook implementation is using Square SDK correctly.")
return True
else:
print("❌ FAILURE: Signature validation failed!")
print("Something is wrong with the signature verification.")
return False
if __name__ == "__main__":
success = test_signature_validation()
sys.exit(0 if success else 1)
File renamed without changes.
Generated
+57
View File
@@ -92,6 +92,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212, upload-time = "2023-09-25T09:04:50.691Z" }, { url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212, upload-time = "2023-09-25T09:04:50.691Z" },
] ]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
@@ -239,6 +248,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
] ]
[[package]]
name = "fastapi"
version = "0.128.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.7.0" version = "1.7.0"
@@ -330,9 +354,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
] ]
[[package]]
name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -949,11 +987,14 @@ dependencies = [
{ name = "asyncio-throttle" }, { name = "asyncio-throttle" },
{ name = "bs4" }, { name = "bs4" },
{ name = "dotenv" }, { name = "dotenv" },
{ name = "fastapi" },
{ name = "gunicorn" },
{ name = "pyinstaller" }, { name = "pyinstaller" },
{ name = "pytest" }, { name = "pytest" },
{ name = "reflex" }, { name = "reflex" },
{ name = "requests" }, { name = "requests" },
{ name = "squareup" }, { name = "squareup" },
{ name = "uvicorn" },
] ]
[package.metadata] [package.metadata]
@@ -964,11 +1005,14 @@ requires-dist = [
{ name = "asyncio-throttle", specifier = ">=1.0.2,<2.0.0" }, { name = "asyncio-throttle", specifier = ">=1.0.2,<2.0.0" },
{ name = "bs4", specifier = ">=0.0.2" }, { name = "bs4", specifier = ">=0.0.2" },
{ name = "dotenv", specifier = ">=0.9.9,<0.10.0" }, { name = "dotenv", specifier = ">=0.9.9,<0.10.0" },
{ name = "fastapi", specifier = ">=0.128.0" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "pyinstaller", specifier = ">=6.14.1,<7.0.0" }, { name = "pyinstaller", specifier = ">=6.14.1,<7.0.0" },
{ name = "pytest", specifier = ">=8.4.2,<9.0.0" }, { name = "pytest", specifier = ">=8.4.2,<9.0.0" },
{ name = "reflex", specifier = ">=0.8.14.post1" }, { name = "reflex", specifier = ">=0.8.14.post1" },
{ name = "requests", specifier = ">=2.32.4,<3.0.0" }, { name = "requests", specifier = ">=2.32.4,<3.0.0" },
{ name = "squareup", specifier = ">=43.0.0.20250618,<44.0.0.0" }, { name = "squareup", specifier = ">=43.0.0.20250618,<44.0.0.0" },
{ name = "uvicorn", specifier = ">=0.40.0" },
] ]
[[package]] [[package]]
@@ -1022,6 +1066,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
] ]
[[package]]
name = "uvicorn"
version = "0.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
[[package]] [[package]]
name = "watchfiles" name = "watchfiles"
version = "1.1.1" version = "1.1.1"