SalexAI commited on
Commit
13c0c13
·
verified ·
1 Parent(s): b320cf4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +259 -123
app.py CHANGED
@@ -1,15 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException
2
  from fastapi.middleware.cors import CORSMiddleware
3
- from fastapi.responses import JSONResponse
4
  import os, json, base64, asyncio
5
  import httpx
6
  from pathlib import Path
7
- from huggingface_hub import CommitScheduler
8
- from huggingface_hub import hf_hub_download
9
- from json import JSONDecodeError
10
-
11
-
12
- from urllib.parse import quote, unquote
13
 
14
  # ==================================================
15
  # APP SETUP
@@ -52,8 +67,7 @@ ALL_TOURS = [
52
  "Epic + Atlantic 1",
53
  "Epic + Maritimes 1",
54
  "Atlantic 1"
55
- ];
56
-
57
 
58
  ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN")
59
  GOOGLE_KEY = os.environ.get("GOOGLE_KEY")
@@ -62,49 +76,24 @@ HF_TOKEN = os.environ.get("HF_TOKEN")
62
  SHEET_ID = "1o0AUq13j-7LZWDhCwFYgq07niZtvOya5iE5bbRQMGWc"
63
 
64
  DATASET_REPO = "SalexAI/mztimgs" # 👈 change if needed
 
 
65
  DATASET_DIR = Path("dataset_cache")
66
  DATASET_DIR.mkdir(parents=True, exist_ok=True)
67
 
 
 
 
 
 
 
 
68
  if not ADMIN_TOKEN:
69
  print("⚠️ WARNING: ADMIN_TOKEN not set")
70
-
71
  if not GOOGLE_KEY:
72
  print("⚠️ WARNING: GOOGLE_KEY not set")
73
-
74
- from urllib.parse import quote
75
-
76
-
77
- def normalize_tour(tour: str) -> str:
78
- return unquote(tour).strip()
79
-
80
-
81
- async def fetch_from_hf(tour: str) -> dict | None:
82
- """
83
- Correct HF fetch:
84
- - filenames are ALWAYS decoded
85
- - HF handles URL encoding internally
86
- """
87
-
88
- filename = f"{tour}.json"
89
- print("🔍 HF HUB DOWNLOAD TRY:", filename)
90
-
91
- try:
92
- path = await asyncio.to_thread(
93
- hf_hub_download,
94
- repo_id=DATASET_REPO,
95
- repo_type="dataset",
96
- filename=f"data/{filename}", # ← decoded, with spaces
97
- token=HF_TOKEN,
98
- )
99
-
100
- print("⬇️ HF HUB DOWNLOADED:", path)
101
-
102
- with open(path, "r", encoding="utf-8") as f:
103
- return json.load(f)
104
-
105
- except Exception as e:
106
- print("❌ HF HUB DOWNLOAD FAILED:", str(e))
107
- return None
108
 
109
  # ==================================================
110
  # HF DATASET COMMIT SCHEDULER
@@ -113,6 +102,9 @@ scheduler = CommitScheduler(
113
  repo_id=DATASET_REPO,
114
  repo_type="dataset",
115
  folder_path=DATASET_DIR,
 
 
 
116
  path_in_repo="data",
117
  token=HF_TOKEN,
118
  )
@@ -120,53 +112,39 @@ scheduler = CommitScheduler(
120
  # ==================================================
121
  # HELPERS
122
  # ==================================================
123
- import re
 
 
 
 
 
124
 
125
  def has_images(data: dict) -> bool:
126
  imgs = data.get("images", {})
127
- return bool(
128
- imgs.get("banner") or
129
- imgs.get("cover") or
130
- imgs.get("carousel")
131
- )
132
-
133
 
134
  def get_fallback_tours(requested: str) -> list[str]:
135
- """
136
- If 'Maritimes' is requested → returns ['Maritimes 1', 'Maritimes 2']
137
- If 'Haida Gwaii' → ['Haida Gwaii 1' ...]
138
- Otherwise empty list
139
- """
140
  base = requested.strip()
141
-
142
- # If request already ends in a number, don't fallback
143
  if re.search(r"\s\d+$", base):
144
  return []
145
 
146
- matches = []
147
- for t in ALL_TOURS:
148
- if t.startswith(base + " "):
149
- matches.append(t)
150
 
151
- # Sort numerically (1,2,3 not 1,10,2)
152
- def tour_num(name):
153
  m = re.search(r"(\d+)$", name)
154
  return int(m.group(1)) if m else 0
155
 
156
  return sorted(matches, key=tour_num)
157
 
158
  def empty_structure():
159
- return {
160
- "images": {
161
- "banner": "",
162
- "cover": "",
163
- "carousel": []
164
- }
165
- }
166
 
167
  def tour_path(tour: str) -> Path:
168
  return DATASET_DIR / f"{tour}.json"
169
 
 
 
 
170
  def load_json(path: Path) -> dict:
171
  if not path.exists():
172
  return empty_structure()
@@ -177,57 +155,62 @@ def save_json(path: Path, data: dict):
177
  with path.open("w", encoding="utf-8") as f:
178
  json.dump(data, f, indent=2)
179
 
180
- def require_admin(token: str):
181
- if not ADMIN_TOKEN or token != ADMIN_TOKEN:
182
- raise HTTPException(status_code=403, detail="Invalid admin token")
183
-
184
- # ==================================================
185
- # GET IMAGE JSON
186
- # ==================================================
187
- @app.get("/imageget/{tour}.json")
188
- async def get_images(tour: str):
189
- tour = normalize_tour(tour)
190
 
191
- # 1️⃣ Try exact match first (current behavior)
192
- path = tour_path(tour)
193
- if path.exists():
194
- data = load_json(path)
195
- if has_images(data):
196
- return data
 
 
 
197
 
198
- data = await fetch_from_hf(tour)
199
- if data and has_images(data):
200
- save_json(path, data)
201
- return data
 
202
 
203
- # 2️⃣ Fallback to numbered tours (NEW)
204
- for alt in get_fallback_tours(tour):
205
- alt_path = tour_path(alt)
206
 
207
- if alt_path.exists():
208
- alt_data = load_json(alt_path)
209
- if has_images(alt_data):
210
- return alt_data
 
 
 
 
 
211
 
212
- alt_data = await fetch_from_hf(alt)
213
- if alt_data and has_images(alt_data):
214
- save_json(alt_path, alt_data)
215
- return alt_data
 
216
 
217
- # 3️⃣ Nothing found
218
- return empty_structure()
 
219
 
 
 
 
220
  @app.get("/")
221
  async def root_status():
222
  tours = []
223
-
224
  for path in DATASET_DIR.glob("*.json"):
225
  try:
226
  with path.open("r", encoding="utf-8") as f:
227
  data = json.load(f)
228
 
229
  images = data.get("images", {})
230
-
231
  banner = bool(images.get("banner"))
232
  cover = bool(images.get("cover"))
233
  carousel_count = len(images.get("carousel", []))
@@ -237,22 +220,55 @@ async def root_status():
237
  "banner": banner,
238
  "cover": cover,
239
  "carousel": carousel_count,
240
- "total_images": int(banner) + int(cover) + carousel_count
 
241
  })
242
-
243
  except Exception as e:
244
- tours.append({
245
- "tour": path.stem,
246
- "error": str(e)
247
- })
248
 
249
  return {
250
  "status": "ok",
251
- "service": "Mile Zero Tours Image API",
252
  "cached_tours": len(tours),
253
- "tours": sorted(tours, key=lambda t: t.get("tour", ""))
254
  }
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  # ==================================================
257
  # UPLOAD IMAGE
258
  # ==================================================
@@ -265,6 +281,7 @@ async def upload_image(
265
  base64_data: str = Form(None),
266
  ):
267
  require_admin(admin_token)
 
268
 
269
  if slot not in ("banner", "cover", "carousel"):
270
  raise HTTPException(status_code=400, detail="Invalid slot")
@@ -290,12 +307,7 @@ async def upload_image(
290
 
291
  save_json(path, data)
292
 
293
- return {
294
- "ok": True,
295
- "tour": tour,
296
- "slot": slot,
297
- "carousel_len": len(data["images"]["carousel"]),
298
- }
299
 
300
  # ==================================================
301
  # DELETE IMAGE
@@ -308,6 +320,7 @@ async def delete_image(
308
  index: int = Form(None),
309
  ):
310
  require_admin(admin_token)
 
311
 
312
  path = tour_path(tour)
313
 
@@ -327,6 +340,129 @@ async def delete_image(
327
 
328
  return {"ok": True}
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  # ==================================================
331
  # GOOGLE SHEETS PROXY (NO KEY LEAK)
332
  # ==================================================
@@ -347,7 +483,7 @@ async def proxy_google_sheets(range: str):
347
  if r.status_code != 200:
348
  return JSONResponse(
349
  status_code=r.status_code,
350
- content={"error": "Google Sheets fetch failed"}
351
  )
352
 
353
- return r.json()
 
1
+ """
2
+ Mile Zero Tours Image API + PDF Upload API (FastAPI)
3
+
4
+ Adds:
5
+ - /pdfupload/{tour} (admin-only) upload a PDF file (multipart)
6
+ - /pdfget/{tour}.pdf (public) fetch PDF (cached -> HF dataset -> 404 if none)
7
+ - /pdfdelete/{tour} (admin-only) delete cached PDF (and schedule HF commit)
8
+
9
+ Storage:
10
+ - JSON stays in: dataset_cache/{tour}.json -> committed to HF under data/
11
+ - PDFs go in: dataset_cache/pdfs/{tour}.pdf -> committed to HF under pdfs/
12
+
13
+ Notes:
14
+ - Keeps your existing image endpoints untouched (except minor imports cleanup).
15
+ - Uses your existing CommitScheduler to commit both JSON + PDFs (same scheduler).
16
+ - Enforces .pdf extension + content-type check + max size (configurable).
17
+ """
18
+
19
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException
20
  from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.responses import JSONResponse, Response
22
  import os, json, base64, asyncio
23
  import httpx
24
  from pathlib import Path
25
+ from huggingface_hub import CommitScheduler, hf_hub_download
26
+ from urllib.parse import unquote
27
+ import re
 
 
 
28
 
29
  # ==================================================
30
  # APP SETUP
 
67
  "Epic + Atlantic 1",
68
  "Epic + Maritimes 1",
69
  "Atlantic 1"
70
+ ]
 
71
 
72
  ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN")
73
  GOOGLE_KEY = os.environ.get("GOOGLE_KEY")
 
76
  SHEET_ID = "1o0AUq13j-7LZWDhCwFYgq07niZtvOya5iE5bbRQMGWc"
77
 
78
  DATASET_REPO = "SalexAI/mztimgs" # 👈 change if needed
79
+
80
+ # Local cache folder that CommitScheduler watches
81
  DATASET_DIR = Path("dataset_cache")
82
  DATASET_DIR.mkdir(parents=True, exist_ok=True)
83
 
84
+ # PDF cache folder (also inside DATASET_DIR so scheduler can commit it)
85
+ PDF_DIR = DATASET_DIR / "pdfs"
86
+ PDF_DIR.mkdir(parents=True, exist_ok=True)
87
+
88
+ # PDF constraints
89
+ MAX_PDF_BYTES = int(os.environ.get("MAX_PDF_BYTES", str(25 * 1024 * 1024))) # default 25MB
90
+
91
  if not ADMIN_TOKEN:
92
  print("⚠️ WARNING: ADMIN_TOKEN not set")
 
93
  if not GOOGLE_KEY:
94
  print("⚠️ WARNING: GOOGLE_KEY not set")
95
+ if not HF_TOKEN:
96
+ print("⚠️ WARNING: HF_TOKEN not set (HF downloads/commits may fail)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  # ==================================================
99
  # HF DATASET COMMIT SCHEDULER
 
102
  repo_id=DATASET_REPO,
103
  repo_type="dataset",
104
  folder_path=DATASET_DIR,
105
+ # Everything inside dataset_cache will be committed under this folder in the repo.
106
+ # So: dataset_cache/Foo.json -> data/Foo.json
107
+ # dataset_cache/pdfs/Foo.pdf -> data/pdfs/Foo.pdf
108
  path_in_repo="data",
109
  token=HF_TOKEN,
110
  )
 
112
  # ==================================================
113
  # HELPERS
114
  # ==================================================
115
+ def normalize_tour(tour: str) -> str:
116
+ return unquote(tour).strip()
117
+
118
+ def require_admin(token: str):
119
+ if not ADMIN_TOKEN or token != ADMIN_TOKEN:
120
+ raise HTTPException(status_code=403, detail="Invalid admin token")
121
 
122
  def has_images(data: dict) -> bool:
123
  imgs = data.get("images", {})
124
+ return bool(imgs.get("banner") or imgs.get("cover") or imgs.get("carousel"))
 
 
 
 
 
125
 
126
  def get_fallback_tours(requested: str) -> list[str]:
 
 
 
 
 
127
  base = requested.strip()
 
 
128
  if re.search(r"\s\d+$", base):
129
  return []
130
 
131
+ matches = [t for t in ALL_TOURS if t.startswith(base + " ")]
 
 
 
132
 
133
+ def tour_num(name: str) -> int:
 
134
  m = re.search(r"(\d+)$", name)
135
  return int(m.group(1)) if m else 0
136
 
137
  return sorted(matches, key=tour_num)
138
 
139
  def empty_structure():
140
+ return {"images": {"banner": "", "cover": "", "carousel": []}}
 
 
 
 
 
 
141
 
142
  def tour_path(tour: str) -> Path:
143
  return DATASET_DIR / f"{tour}.json"
144
 
145
+ def pdf_path(tour: str) -> Path:
146
+ return PDF_DIR / f"{tour}.pdf"
147
+
148
  def load_json(path: Path) -> dict:
149
  if not path.exists():
150
  return empty_structure()
 
155
  with path.open("w", encoding="utf-8") as f:
156
  json.dump(data, f, indent=2)
157
 
158
+ async def fetch_from_hf_json(tour: str) -> dict | None:
159
+ filename = f"{tour}.json"
160
+ print("🔍 HF HUB DOWNLOAD TRY (json):", filename)
 
 
 
 
 
 
 
161
 
162
+ try:
163
+ path = await asyncio.to_thread(
164
+ hf_hub_download,
165
+ repo_id=DATASET_REPO,
166
+ repo_type="dataset",
167
+ filename=f"data/{filename}",
168
+ token=HF_TOKEN,
169
+ )
170
+ print("⬇️ HF HUB DOWNLOADED (json):", path)
171
 
172
+ with open(path, "r", encoding="utf-8") as f:
173
+ return json.load(f)
174
+ except Exception as e:
175
+ print("❌ HF HUB DOWNLOAD FAILED (json):", str(e))
176
+ return None
177
 
178
+ async def fetch_from_hf_pdf_bytes(tour: str) -> bytes | None:
179
+ filename = f"{tour}.pdf"
180
+ print("🔍 HF HUB DOWNLOAD TRY (pdf):", filename)
181
 
182
+ try:
183
+ path = await asyncio.to_thread(
184
+ hf_hub_download,
185
+ repo_id=DATASET_REPO,
186
+ repo_type="dataset",
187
+ filename=f"data/pdfs/{filename}",
188
+ token=HF_TOKEN,
189
+ )
190
+ print("⬇️ HF HUB DOWNLOADED (pdf):", path)
191
 
192
+ with open(path, "rb") as f:
193
+ return f.read()
194
+ except Exception as e:
195
+ print("❌ HF HUB DOWNLOAD FAILED (pdf):", str(e))
196
+ return None
197
 
198
+ def sniff_pdf(raw: bytes) -> bool:
199
+ # PDFs start with: %PDF-
200
+ return raw[:5] == b"%PDF-"
201
 
202
+ # ==================================================
203
+ # ROUTES
204
+ # ==================================================
205
  @app.get("/")
206
  async def root_status():
207
  tours = []
 
208
  for path in DATASET_DIR.glob("*.json"):
209
  try:
210
  with path.open("r", encoding="utf-8") as f:
211
  data = json.load(f)
212
 
213
  images = data.get("images", {})
 
214
  banner = bool(images.get("banner"))
215
  cover = bool(images.get("cover"))
216
  carousel_count = len(images.get("carousel", []))
 
220
  "banner": banner,
221
  "cover": cover,
222
  "carousel": carousel_count,
223
+ "total_images": int(banner) + int(cover) + carousel_count,
224
+ "has_pdf": pdf_path(path.stem).exists(),
225
  })
 
226
  except Exception as e:
227
+ tours.append({"tour": path.stem, "error": str(e)})
 
 
 
228
 
229
  return {
230
  "status": "ok",
231
+ "service": "Mile Zero Tours Image + PDF API",
232
  "cached_tours": len(tours),
233
+ "tours": sorted(tours, key=lambda t: t.get("tour", "")),
234
  }
235
 
236
+ # ==================================================
237
+ # GET IMAGE JSON
238
+ # ==================================================
239
+ @app.get("/imageget/{tour}.json")
240
+ async def get_images(tour: str):
241
+ tour = normalize_tour(tour)
242
+
243
+ # 1) exact cache
244
+ path = tour_path(tour)
245
+ if path.exists():
246
+ data = load_json(path)
247
+ if has_images(data):
248
+ return data
249
+
250
+ # 2) exact HF
251
+ data = await fetch_from_hf_json(tour)
252
+ if data and has_images(data):
253
+ save_json(path, data)
254
+ return data
255
+
256
+ # 3) fallback numbered tours
257
+ for alt in get_fallback_tours(tour):
258
+ alt_path = tour_path(alt)
259
+
260
+ if alt_path.exists():
261
+ alt_data = load_json(alt_path)
262
+ if has_images(alt_data):
263
+ return alt_data
264
+
265
+ alt_data = await fetch_from_hf_json(alt)
266
+ if alt_data and has_images(alt_data):
267
+ save_json(alt_path, alt_data)
268
+ return alt_data
269
+
270
+ return empty_structure()
271
+
272
  # ==================================================
273
  # UPLOAD IMAGE
274
  # ==================================================
 
281
  base64_data: str = Form(None),
282
  ):
283
  require_admin(admin_token)
284
+ tour = normalize_tour(tour)
285
 
286
  if slot not in ("banner", "cover", "carousel"):
287
  raise HTTPException(status_code=400, detail="Invalid slot")
 
307
 
308
  save_json(path, data)
309
 
310
+ return {"ok": True, "tour": tour, "slot": slot, "carousel_len": len(data["images"]["carousel"])}
 
 
 
 
 
311
 
312
  # ==================================================
313
  # DELETE IMAGE
 
320
  index: int = Form(None),
321
  ):
322
  require_admin(admin_token)
323
+ tour = normalize_tour(tour)
324
 
325
  path = tour_path(tour)
326
 
 
340
 
341
  return {"ok": True}
342
 
343
+ # ==================================================
344
+ # PDF UPLOAD (NEW)
345
+ # ==================================================
346
+ @app.post("/pdfupload/{tour}")
347
+ async def upload_pdf(
348
+ tour: str,
349
+ admin_token: str = Form(...),
350
+ file: UploadFile = File(...),
351
+ ):
352
+ require_admin(admin_token)
353
+ tour = normalize_tour(tour)
354
+
355
+ # Basic content-type / filename checks (clients are often inconsistent, so we verify bytes too)
356
+ filename = (file.filename or "").lower()
357
+ if not (filename.endswith(".pdf") or file.content_type == "application/pdf"):
358
+ raise HTTPException(status_code=400, detail="Only PDF files are allowed")
359
+
360
+ raw = await file.read()
361
+
362
+ if len(raw) == 0:
363
+ raise HTTPException(status_code=400, detail="Empty file")
364
+ if len(raw) > MAX_PDF_BYTES:
365
+ raise HTTPException(status_code=413, detail=f"PDF too large (max {MAX_PDF_BYTES} bytes)")
366
+ if not sniff_pdf(raw):
367
+ raise HTTPException(status_code=400, detail="File does not look like a valid PDF")
368
+
369
+ path = pdf_path(tour)
370
+
371
+ with scheduler.lock:
372
+ path.write_bytes(raw)
373
+
374
+ return {
375
+ "ok": True,
376
+ "tour": tour,
377
+ "bytes": len(raw),
378
+ "pdf": f"/pdfget/{tour}.pdf",
379
+ }
380
+
381
+ # ==================================================
382
+ # PDF GET (NEW)
383
+ # ==================================================
384
+ @app.get("/pdfget/{tour}.pdf")
385
+ async def get_pdf(tour: str):
386
+ tour = normalize_tour(tour)
387
+
388
+ # 1) local cache
389
+ path = pdf_path(tour)
390
+ if path.exists():
391
+ raw = path.read_bytes()
392
+ return Response(
393
+ content=raw,
394
+ media_type="application/pdf",
395
+ headers={
396
+ "Cache-Control": "public, max-age=300",
397
+ "Content-Disposition": f'inline; filename="{tour}.pdf"',
398
+ },
399
+ )
400
+
401
+ # 2) HF fetch -> cache -> return
402
+ raw = await fetch_from_hf_pdf_bytes(tour)
403
+ if raw:
404
+ # safety: avoid caching huge or non-pdf blobs
405
+ if len(raw) <= MAX_PDF_BYTES and sniff_pdf(raw):
406
+ with scheduler.lock:
407
+ path.write_bytes(raw)
408
+ return Response(
409
+ content=raw,
410
+ media_type="application/pdf",
411
+ headers={
412
+ "Cache-Control": "public, max-age=300",
413
+ "Content-Disposition": f'inline; filename="{tour}.pdf"',
414
+ },
415
+ )
416
+
417
+ # 3) fallback numbered tours (optional)
418
+ for alt in get_fallback_tours(tour):
419
+ alt_path = pdf_path(alt)
420
+ if alt_path.exists():
421
+ raw2 = alt_path.read_bytes()
422
+ return Response(
423
+ content=raw2,
424
+ media_type="application/pdf",
425
+ headers={
426
+ "Cache-Control": "public, max-age=300",
427
+ "Content-Disposition": f'inline; filename="{alt}.pdf"',
428
+ },
429
+ )
430
+
431
+ raw2 = await fetch_from_hf_pdf_bytes(alt)
432
+ if raw2:
433
+ if len(raw2) <= MAX_PDF_BYTES and sniff_pdf(raw2):
434
+ with scheduler.lock:
435
+ alt_path.write_bytes(raw2)
436
+ return Response(
437
+ content=raw2,
438
+ media_type="application/pdf",
439
+ headers={
440
+ "Cache-Control": "public, max-age=300",
441
+ "Content-Disposition": f'inline; filename="{alt}.pdf"',
442
+ },
443
+ )
444
+
445
+ raise HTTPException(status_code=404, detail="PDF not found")
446
+
447
+ # ==================================================
448
+ # PDF DELETE (NEW)
449
+ # ==================================================
450
+ @app.post("/pdfdelete/{tour}")
451
+ async def delete_pdf(
452
+ tour: str,
453
+ admin_token: str = Form(...),
454
+ ):
455
+ require_admin(admin_token)
456
+ tour = normalize_tour(tour)
457
+
458
+ path = pdf_path(tour)
459
+
460
+ with scheduler.lock:
461
+ if path.exists():
462
+ path.unlink()
463
+
464
+ return {"ok": True, "tour": tour, "deleted": True}
465
+
466
  # ==================================================
467
  # GOOGLE SHEETS PROXY (NO KEY LEAK)
468
  # ==================================================
 
483
  if r.status_code != 200:
484
  return JSONResponse(
485
  status_code=r.status_code,
486
+ content={"error": "Google Sheets fetch failed"},
487
  )
488
 
489
+ return r.json()