From db7c31260b3b1dd2a960d0695333c56bcb472e64 Mon Sep 17 00:00:00 2001 From: Malin Date: Fri, 20 Mar 2026 10:31:19 +0100 Subject: [PATCH] feat: initial MTZ Excel-to-JSON API wrapper --- .env.example | 3 ++ .gitignore | 3 ++ Dockerfile | 7 ++++ docker-compose.yml | 8 ++++ main.py | 99 ++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 +++ 6 files changed, 125 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..78330f5 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +API_KEY=change-me-to-a-strong-secret +EXCEL_URL=https://files.constantcontact.com/d8c838c7001/d68c2f97-5cd3-40c9-b0df-04296ccdcc7b.xlsx +REFRESH_HOURS=6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0e24a13 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +EXPOSE 8080 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6ff655b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3.9" +services: + mtz-api: + build: . + ports: + - "8080:8080" + env_file: .env + restart: unless-stopped diff --git a/main.py b/main.py new file mode 100644 index 0000000..4fc3d44 --- /dev/null +++ b/main.py @@ -0,0 +1,99 @@ +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") +products_cache = [] +last_refresh = None +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 + resp = requests.get(EXCEL_URL, timeout=60) + resp.raise_for_status() + wb = load_workbook(BytesIO(resp.content), read_only=True, data_only=True) + ws = wb.active + rows = list(ws.iter_rows(min_row=6, values_only=True)) + parsed = [] + for row in rows: + if row[1] is None: # col B (index 1) = item_code + continue + ean_raw = row[3] # col D + ean = str(int(ean_raw)) if ean_raw is not None else "" + brand_raw = row[9] # col J + brand = str(brand_raw) if brand_raw is not None else "" + min_box = row[10] if row[10] is not None else 1 # col K + parsed.append( + { + "item_code": int(row[1]), + "ean": ean, + "description": str(row[2] or ""), + "price_usd": float(row[4] or 0), + "stock": int(row[5] or 0), + "brand": brand, + "min_box_qty": int(min_box), + } + ) + with cache_lock: + products_cache = parsed + 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(): + return { + "status": "ok", + "product_count": len(products_cache), + "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() + return {"status": "ok", "product_count": len(products_cache)} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..daee340 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.110.0 +uvicorn[standard]==0.29.0 +openpyxl==3.1.2 +requests==2.31.0 +python-dotenv==1.0.1