Helps diagnose whether the product cap is from EAN filtering or a downstream limit. health and refresh now return: product_count, rows_processed, rows_skipped. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
4.1 KiB
Python
152 lines
4.1 KiB
Python
import os
|
|
import threading
|
|
import time
|
|
import requests
|
|
from fastapi import FastAPI, HTTPException, Security, Depends
|
|
from fastapi.security.api_key import APIKeyHeader
|
|
from openpyxl import load_workbook
|
|
from io import BytesIO
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
API_KEY = os.getenv("API_KEY", "change-me")
|
|
EXCEL_URL = os.getenv(
|
|
"EXCEL_URL",
|
|
"https://files.constantcontact.com/d8c838c7001/d68c2f97-5cd3-40c9-b0df-04296ccdcc7b.xlsx",
|
|
)
|
|
REFRESH_HOURS = int(os.getenv("REFRESH_HOURS", "6"))
|
|
|
|
app = FastAPI(title="MTZ Stock API", version="1.0.0")
|
|
|
|
|
|
def safe_int(value, default):
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, (int, float)):
|
|
return int(value)
|
|
try:
|
|
return int(str(value).strip())
|
|
except (ValueError, TypeError):
|
|
return default
|
|
|
|
|
|
def safe_float(value, default):
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
try:
|
|
return float(str(value).strip())
|
|
except (ValueError, TypeError):
|
|
return default
|
|
|
|
|
|
products_cache = []
|
|
last_refresh = None
|
|
rows_processed = 0
|
|
rows_skipped = 0
|
|
cache_lock = threading.Lock()
|
|
api_key_header = APIKeyHeader(name="X-API-Key")
|
|
|
|
|
|
def verify_key(key: str = Security(api_key_header)):
|
|
if key != API_KEY:
|
|
raise HTTPException(status_code=403, detail="Invalid API Key")
|
|
return key
|
|
|
|
|
|
def download_and_parse():
|
|
global products_cache, last_refresh, rows_processed, rows_skipped
|
|
resp = requests.get(EXCEL_URL, timeout=60)
|
|
resp.raise_for_status()
|
|
# read_only=True would stop at the sheet's declared <dimension ref> attribute,
|
|
# silently missing any rows added beyond the original range.
|
|
wb = load_workbook(BytesIO(resp.content), data_only=True)
|
|
ws = wb.active
|
|
rows = list(ws.iter_rows(min_row=6, values_only=True))
|
|
parsed = []
|
|
skipped = 0
|
|
for row in rows:
|
|
if row[1] is None: # col B (index 1) = item_code — empty row
|
|
skipped += 1
|
|
continue
|
|
ean_raw = row[3] # col D
|
|
if ean_raw is None:
|
|
ean = ""
|
|
elif isinstance(ean_raw, (int, float)):
|
|
ean = str(int(ean_raw))
|
|
else:
|
|
ean = str(ean_raw).strip()
|
|
|
|
# Skip products with blank or non-numeric EAN codes
|
|
if not ean or not ean.isdigit():
|
|
skipped += 1
|
|
continue
|
|
|
|
brand_raw = row[9] # col J
|
|
brand = str(brand_raw) if brand_raw is not None else ""
|
|
min_box_raw = row[10] # col K
|
|
parsed.append(
|
|
{
|
|
"item_code": safe_int(row[1], 0),
|
|
"ean": ean,
|
|
"description": str(row[2] or ""),
|
|
"price_usd": safe_float(row[4], 0.0),
|
|
"stock": safe_int(row[5], 0),
|
|
"brand": brand,
|
|
"min_box_qty": safe_int(min_box_raw, 1),
|
|
}
|
|
)
|
|
with cache_lock:
|
|
products_cache = parsed
|
|
rows_processed = len(rows)
|
|
rows_skipped = skipped
|
|
last_refresh = time.time()
|
|
wb.close()
|
|
|
|
|
|
def background_refresh():
|
|
while True:
|
|
time.sleep(REFRESH_HOURS * 3600)
|
|
try:
|
|
download_and_parse()
|
|
except Exception as e:
|
|
print(f"Background refresh failed: {e}")
|
|
|
|
|
|
@app.on_event("startup")
|
|
def startup():
|
|
download_and_parse()
|
|
t = threading.Thread(target=background_refresh, daemon=True)
|
|
t.start()
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
with cache_lock:
|
|
return {
|
|
"status": "ok",
|
|
"product_count": len(products_cache),
|
|
"rows_processed": rows_processed,
|
|
"rows_skipped": rows_skipped,
|
|
"last_refresh": last_refresh,
|
|
}
|
|
|
|
|
|
@app.get("/api/products", dependencies=[Depends(verify_key)])
|
|
def get_products():
|
|
with cache_lock:
|
|
return products_cache
|
|
|
|
|
|
@app.post("/api/refresh", dependencies=[Depends(verify_key)])
|
|
def refresh():
|
|
download_and_parse()
|
|
with cache_lock:
|
|
return {
|
|
"status": "ok",
|
|
"product_count": len(products_cache),
|
|
"rows_processed": rows_processed,
|
|
"rows_skipped": rows_skipped,
|
|
}
|