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 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, }