mztapiv2 / app.py
SalexAI's picture
Update app.py
6fbb91a verified
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import os, json, base64, asyncio
import httpx
from pathlib import Path
from huggingface_hub import CommitScheduler
from huggingface_hub import hf_hub_download
from json import JSONDecodeError
from urllib.parse import quote, unquote
# ==================================================
# APP SETUP
# ==================================================
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ==================================================
# CONFIG
# ==================================================
ALL_TOURS = [
"Benelux",
"Epic Rail 1", "Epic Rail 2", "Epic Rail 3",
"Haida Gwaii 1", "Haida Gwaii 2", "Haida Gwaii 3",
"Haida Gwaii 4", "Haida Gwaii 5", "Haida Gwaii 6", "Haida Gwaii 7",
"Harrison",
"Ireland",
"Island Hopping", "Island Hopping 2",
"Kootenays",
"Maritimes 1", "Maritimes 2",
"New Zealand",
"Newfoundland 1", "Newfoundland 2", "Newfoundland 3", "Newfoundland 4",
"Okanagan",
"Portugal",
"Quebec",
"Quebec Holiday",
"Scotland",
"Sea to Sky",
"Skeena",
"Tofino 1", "Tofino 2",
"Van Isle",
"Yukon 1", "Yukon 2", "Yukon 3", "Yukon 4",
"Yukon Winter",
]
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN")
GOOGLE_KEY = os.environ.get("GOOGLE_KEY")
HF_TOKEN = os.environ.get("HF_TOKEN")
SHEET_ID = "1o0AUq13j-7LZWDhCwFYgq07niZtvOya5iE5bbRQMGWc"
DATASET_REPO = "SalexAI/mztimgs" # 👈 change if needed
DATASET_DIR = Path("dataset_cache")
DATASET_DIR.mkdir(parents=True, exist_ok=True)
if not ADMIN_TOKEN:
print("⚠️ WARNING: ADMIN_TOKEN not set")
if not GOOGLE_KEY:
print("⚠️ WARNING: GOOGLE_KEY not set")
from urllib.parse import quote
def normalize_tour(tour: str) -> str:
return unquote(tour).strip()
async def fetch_from_hf(tour: str) -> dict | None:
"""
Correct HF fetch:
- filenames are ALWAYS decoded
- HF handles URL encoding internally
"""
filename = f"{tour}.json"
print("🔍 HF HUB DOWNLOAD TRY:", filename)
try:
path = await asyncio.to_thread(
hf_hub_download,
repo_id=DATASET_REPO,
repo_type="dataset",
filename=f"data/{filename}", # ← decoded, with spaces
token=HF_TOKEN,
)
print("⬇️ HF HUB DOWNLOADED:", path)
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print("❌ HF HUB DOWNLOAD FAILED:", str(e))
return None
# ==================================================
# HF DATASET COMMIT SCHEDULER
# ==================================================
scheduler = CommitScheduler(
repo_id=DATASET_REPO,
repo_type="dataset",
folder_path=DATASET_DIR,
path_in_repo="data",
token=HF_TOKEN,
)
# ==================================================
# HELPERS
# ==================================================
import re
def has_images(data: dict) -> bool:
imgs = data.get("images", {})
return bool(
imgs.get("banner") or
imgs.get("cover") or
imgs.get("carousel")
)
def get_fallback_tours(requested: str) -> list[str]:
"""
If 'Maritimes' is requested → returns ['Maritimes 1', 'Maritimes 2']
If 'Haida Gwaii' → ['Haida Gwaii 1' ...]
Otherwise empty list
"""
base = requested.strip()
# If request already ends in a number, don't fallback
if re.search(r"\s\d+$", base):
return []
matches = []
for t in ALL_TOURS:
if t.startswith(base + " "):
matches.append(t)
# Sort numerically (1,2,3 not 1,10,2)
def tour_num(name):
m = re.search(r"(\d+)$", name)
return int(m.group(1)) if m else 0
return sorted(matches, key=tour_num)
def empty_structure():
return {
"images": {
"banner": "",
"cover": "",
"carousel": []
}
}
def tour_path(tour: str) -> Path:
return DATASET_DIR / f"{tour}.json"
def load_json(path: Path) -> dict:
if not path.exists():
return empty_structure()
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def save_json(path: Path, data: dict):
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
def require_admin(token: str):
if not ADMIN_TOKEN or token != ADMIN_TOKEN:
raise HTTPException(status_code=403, detail="Invalid admin token")
# ==================================================
# GET IMAGE JSON
# ==================================================
@app.get("/imageget/{tour}.json")
async def get_images(tour: str):
tour = normalize_tour(tour)
# 1️⃣ Try exact match first (current behavior)
path = tour_path(tour)
if path.exists():
data = load_json(path)
if has_images(data):
return data
data = await fetch_from_hf(tour)
if data and has_images(data):
save_json(path, data)
return data
# 2️⃣ Fallback to numbered tours (NEW)
for alt in get_fallback_tours(tour):
alt_path = tour_path(alt)
if alt_path.exists():
alt_data = load_json(alt_path)
if has_images(alt_data):
return alt_data
alt_data = await fetch_from_hf(alt)
if alt_data and has_images(alt_data):
save_json(alt_path, alt_data)
return alt_data
# 3️⃣ Nothing found
return empty_structure()
@app.get("/")
async def root_status():
tours = []
for path in DATASET_DIR.glob("*.json"):
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
images = data.get("images", {})
banner = bool(images.get("banner"))
cover = bool(images.get("cover"))
carousel_count = len(images.get("carousel", []))
tours.append({
"tour": path.stem,
"banner": banner,
"cover": cover,
"carousel": carousel_count,
"total_images": int(banner) + int(cover) + carousel_count
})
except Exception as e:
tours.append({
"tour": path.stem,
"error": str(e)
})
return {
"status": "ok",
"service": "Mile Zero Tours Image API",
"cached_tours": len(tours),
"tours": sorted(tours, key=lambda t: t.get("tour", ""))
}
# ==================================================
# UPLOAD IMAGE
# ==================================================
@app.post("/imageupload/{tour}")
async def upload_image(
tour: str,
admin_token: str = Form(...),
slot: str = Form(...), # banner | cover | carousel
file: UploadFile = File(None),
base64_data: str = Form(None),
):
require_admin(admin_token)
if slot not in ("banner", "cover", "carousel"):
raise HTTPException(status_code=400, detail="Invalid slot")
if not file and not base64_data:
raise HTTPException(status_code=400, detail="No image provided")
path = tour_path(tour)
with scheduler.lock:
data = load_json(path)
if file:
raw = await file.read()
b64 = base64.b64encode(raw).decode("utf-8")
else:
b64 = base64_data.strip()
if slot == "carousel":
data["images"]["carousel"].append(b64)
else:
data["images"][slot] = b64
save_json(path, data)
return {
"ok": True,
"tour": tour,
"slot": slot,
"carousel_len": len(data["images"]["carousel"]),
}
# ==================================================
# DELETE IMAGE
# ==================================================
@app.post("/imagedelete/{tour}")
async def delete_image(
tour: str,
admin_token: str = Form(...),
slot: str = Form(...),
index: int = Form(None),
):
require_admin(admin_token)
path = tour_path(tour)
with scheduler.lock:
data = load_json(path)
if slot == "carousel":
if index is None or index >= len(data["images"]["carousel"]):
raise HTTPException(status_code=400, detail="Invalid index")
data["images"]["carousel"].pop(index)
elif slot in ("banner", "cover"):
data["images"][slot] = ""
else:
raise HTTPException(status_code=400, detail="Invalid slot")
save_json(path, data)
return {"ok": True}
# ==================================================
# GOOGLE SHEETS PROXY (NO KEY LEAK)
# ==================================================
@app.get("/sheets/{range:path}")
async def proxy_google_sheets(range: str):
if not GOOGLE_KEY:
raise HTTPException(status_code=503, detail="Sheets proxy not configured")
url = (
f"https://sheets.googleapis.com/v4/spreadsheets/"
f"{SHEET_ID}/values/{range}"
f"?key={GOOGLE_KEY}"
)
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url)
if r.status_code != 200:
return JSONResponse(
status_code=r.status_code,
content={"error": "Google Sheets fetch failed"}
)
return r.json()