Shaikhsarib commited on
Commit
bbd16b8
·
verified ·
1 Parent(s): 57e072f

Delete main.py

Browse files
Files changed (1) hide show
  1. main.py +0 -723
main.py DELETED
@@ -1,723 +0,0 @@
1
- """
2
- Eatlytic v4 — Modular FastAPI Application
3
- =========================================
4
- Architecture:
5
- app/models/db.py — SQLite schema, connection, caching
6
- app/services/auth.py — User accounts, OTP, sessions
7
- app/services/payments.py— Razorpay integration (real payments)
8
- app/services/image.py — Blur detection + Wiener deblurring pipeline
9
- app/services/ocr.py — EasyOCR wrapper, label detection
10
- app/services/llm.py — LLM abstraction, food DB population
11
- app/routes/auth.py — /auth/* endpoints
12
- app/routes/payments.py — /payments/* endpoints
13
- app/routes/food_db.py — /food-db/* endpoints (Phase 2 moat)
14
- app/routes/benchmarks.py— /benchmarks/* endpoints (accuracy system)
15
- main.py — app factory, legacy routes, startup
16
-
17
- Run: uvicorn main:app --host 0.0.0.0 --port 7860
18
- Test: pytest tests/ -v
19
- """
20
-
21
- import os
22
- import re
23
- import json
24
- import asyncio
25
- import logging
26
- import hashlib
27
- import datetime
28
- import secrets
29
- from io import BytesIO
30
-
31
- from fastapi import FastAPI, File, UploadFile, Form, Request, HTTPException, Security
32
- from fastapi.middleware.cors import CORSMiddleware
33
- from fastapi.responses import FileResponse, JSONResponse, Response
34
- from fastapi.security import APIKeyHeader
35
- from slowapi import Limiter, _rate_limit_exceeded_handler
36
- from slowapi.util import get_remote_address
37
- from slowapi.errors import RateLimitExceeded
38
-
39
- # ── DuckDuckGo: guarded import ─────────────────────────────────────────
40
- try:
41
- from duckduckgo_search import DDGS as _DDGS; _DDGS_OK = True
42
- except Exception:
43
- _DDGS = None; _DDGS_OK = False
44
-
45
- logging.basicConfig(level=logging.INFO)
46
- logger = logging.getLogger(__name__)
47
-
48
- # ── App factory ────────────────────────────────────────────────────────
49
- limiter = Limiter(key_func=get_remote_address)
50
- app = FastAPI(title="Eatlytic v4 — Food Intelligence", version="4.0")
51
- app.state.limiter = limiter
52
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
53
-
54
- _ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
55
- app.add_middleware(
56
- CORSMiddleware,
57
- allow_origins = [_ALLOWED_ORIGIN],
58
- allow_methods = ["GET", "POST", "DELETE", "PATCH"],
59
- allow_headers = ["*"],
60
- allow_credentials = _ALLOWED_ORIGIN != "*",
61
- )
62
-
63
- # ── DB init at startup ─────────────────────────────────────────────────
64
- from app.models.db import init_db, db_conn
65
- init_db()
66
-
67
- # ── Import + mount modular routers ────────────────────────────────────
68
- from app.routes.auth import router as auth_router
69
- from app.routes.payments import router as payments_router
70
- from app.routes.food_db import router as food_db_router
71
- from app.routes.benchmarks import router as benchmarks_router
72
-
73
- app.include_router(auth_router)
74
- app.include_router(payments_router)
75
- app.include_router(food_db_router)
76
- app.include_router(benchmarks_router)
77
-
78
- # ── Import services ────────────────────────────────────────────────────
79
- from app.services.image import (
80
- validate_image, assess_image_quality, deblur_and_enhance,
81
- image_to_b64, ocr_quality_score,
82
- )
83
- from app.services.ocr import run_ocr, detect_label_presence
84
- from app.services.llm import (
85
- analyse_label, upsert_food_product, MEDICAL_DISCLAIMER, LANGUAGE_MAP
86
- )
87
- from app.services.auth import (
88
- get_user_from_token, check_and_increment_scan_user,
89
- update_streak_user,
90
- )
91
-
92
- FREE_SCAN_LIMIT = int(os.environ.get("FREE_SCAN_LIMIT", "10"))
93
- GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
94
- api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
95
-
96
-
97
- # ── Auth helpers ───────────────────────────────────────────────────────
98
- def _get_request_user(request: Request):
99
- """Extract authenticated user from Bearer token (optional)."""
100
- auth = request.headers.get("Authorization", "")
101
- token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
102
- return get_user_from_token(token) if token else None
103
-
104
-
105
- def _device_key(request: Request) -> str:
106
- ip = request.client.host if request.client else "unknown"
107
- ua = request.headers.get("user-agent", "")
108
- return hashlib.md5(f"{ip}:{ua}".encode()).hexdigest()[:16]
109
-
110
-
111
- def _ensure_device(device_key: str):
112
- try:
113
- with db_conn() as conn:
114
- conn.execute("INSERT OR IGNORE INTO devices(device_key) VALUES(?)", (device_key,))
115
- except Exception:
116
- pass
117
-
118
-
119
- def _check_scan_quota(user, device_key: str) -> dict:
120
- """Check quota for authenticated user; fall back to device for anonymous."""
121
- if user:
122
- return check_and_increment_scan_user(user["id"])
123
- # Legacy anonymous path
124
- month_key = datetime.date.today().isoformat()[:7]
125
- _ensure_device(device_key)
126
- with db_conn() as conn:
127
- row = conn.execute(
128
- "SELECT is_pro, month, scan_count FROM devices WHERE device_key=?", (device_key,)
129
- ).fetchone()
130
- if not row:
131
- return {"allowed": False, "scans_used": 0, "scans_remaining": 0, "is_pro": False}
132
- if row["month"] != month_key:
133
- conn.execute("UPDATE devices SET month=?, scan_count=0 WHERE device_key=?",
134
- (month_key, device_key))
135
- count = 0
136
- else:
137
- count = row["scan_count"]
138
- if row["is_pro"]:
139
- conn.execute("UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?", (device_key,))
140
- return {"allowed": True, "scans_used": count+1, "scans_remaining": 9999, "is_pro": True}
141
- if count >= FREE_SCAN_LIMIT:
142
- return {"allowed": False, "scans_used": count, "scans_remaining": 0, "is_pro": False}
143
- conn.execute("UPDATE devices SET scan_count=scan_count+1 WHERE device_key=?", (device_key,))
144
- new = count + 1
145
- return {"allowed": True, "scans_used": new,
146
- "scans_remaining": FREE_SCAN_LIMIT - new, "is_pro": False}
147
-
148
-
149
- def _get_live_search(query: str) -> str:
150
- if not _DDGS_OK:
151
- return "Web search unavailable."
152
- try:
153
- with _DDGS() as ddgs:
154
- results = [f"{r['title']}: {r['body']}" for r in ddgs.text(query, max_results=3)]
155
- return "\n".join(results) if results else "No web data."
156
- except Exception as exc:
157
- logger.warning("Web search: %s", exc)
158
- return "No web data."
159
-
160
-
161
- def _verify_api_key(api_key: str = Security(api_key_header)):
162
- if not api_key:
163
- return None
164
- with db_conn() as conn:
165
- row = conn.execute(
166
- "SELECT * FROM api_keys WHERE api_key=? AND active=1", (api_key,)
167
- ).fetchone()
168
- return dict(row) if row else None
169
-
170
-
171
- # ══════════════════════════════════════════════════════════════════════
172
- # CORE ROUTES
173
- # ══════════════════════════════════════════════════════════════════════
174
-
175
- @app.get("/")
176
- async def home():
177
- return FileResponse("index.html")
178
-
179
-
180
- @app.get("/health")
181
- async def health():
182
- return {"status": "ok", "version": "4.0", "db": "sqlite-wal"}
183
-
184
-
185
- @app.post("/check-image")
186
- @limiter.limit("30/minute")
187
- async def check_image(request: Request, image: UploadFile = File(...)):
188
- content = await image.read()
189
- content = validate_image(content)
190
- return assess_image_quality(content)
191
-
192
-
193
- @app.post("/enhance-preview")
194
- @limiter.limit("20/minute")
195
- async def enhance_preview(request: Request, image: UploadFile = File(...)):
196
- content = await image.read()
197
- content = validate_image(content)
198
- quality = assess_image_quality(content)
199
- if not quality["is_blurry"]:
200
- return JSONResponse({"deblurred": False, "message": "Image already clear.", "quality": quality})
201
- enhanced, method_log = deblur_and_enhance(content, quality["blur_severity"])
202
- return JSONResponse({"deblurred": True, "image_b64": image_to_b64(enhanced),
203
- "method_log": method_log, "quality_before": quality})
204
-
205
-
206
- @app.post("/ocr")
207
- @limiter.limit("20/minute")
208
- async def perform_ocr(request: Request, image: UploadFile = File(...),
209
- language: str = Form("en")):
210
- content = await image.read()
211
- content = validate_image(content)
212
- return run_ocr(content, language)
213
-
214
-
215
- @app.post("/analyze")
216
- @limiter.limit("15/minute")
217
- async def analyze_product(
218
- request : Request,
219
- persona : str = Form(...),
220
- age_group : str = Form("adult"),
221
- product_category : str = Form("general"),
222
- language : str = Form("en"),
223
- extracted_text : str = Form(None),
224
- image : UploadFile = File(...),
225
- ):
226
- if not GROQ_API_KEY:
227
- return JSONResponse({"error": "Server error: GROQ_API_KEY not set"})
228
-
229
- user = _get_request_user(request)
230
- device_key = _device_key(request)
231
- scan_check = _check_scan_quota(user, device_key)
232
-
233
- if not scan_check["allowed"]:
234
- return JSONResponse(status_code=402, content={
235
- "error" : "scan_limit_reached",
236
- "message" : f"You've used all {FREE_SCAN_LIMIT} free scans this month.",
237
- "upgrade_url": "/payments/create-order",
238
- })
239
-
240
- try:
241
- content = await image.read()
242
- content = validate_image(content) # ← 10MB limit enforcement
243
- quality = assess_image_quality(content)
244
- blur_info = {"detected": quality["is_blurry"], "severity": quality["blur_severity"],
245
- "score": quality["blur_score"], "deblurred": False,
246
- "method_log": None, "image_b64": None, "ocr_source": "original"}
247
- working = content
248
-
249
- if quality["is_blurry"]:
250
- try:
251
- enhanced, method_log = deblur_and_enhance(content, quality["blur_severity"])
252
- if (ocr_quality_score(run_ocr(enhanced, language)) >=
253
- ocr_quality_score(run_ocr(content, language)) * 0.85):
254
- working = enhanced
255
- blur_info["deblurred"] = True
256
- blur_info["method_log"] = method_log
257
- blur_info["image_b64"] = image_to_b64(enhanced)
258
- blur_info["ocr_source"] = "deblurred"
259
- extracted_text = None
260
- except Exception as exc:
261
- logger.warning("Deblur failed: %s", exc)
262
-
263
- if not extracted_text:
264
- ocr_result = run_ocr(working, language)
265
- extracted_text = ocr_result["text"]
266
- ocr_word_count = ocr_result["word_count"]
267
- else:
268
- ocr_word_count = len(extracted_text.split())
269
-
270
- if not extracted_text or ocr_word_count == 0:
271
- return JSONResponse({"error": "no_text",
272
- "message": "No text found. Make sure the label side is facing the camera.",
273
- "tip": "flip_product"})
274
-
275
- label_check = detect_label_presence(extracted_text)
276
- if not label_check["has_label"]:
277
- tip = label_check["suggestion"] or "flip_product"
278
- msg = ("This looks like the front of the product. Flip it over and scan the back label."
279
- if tip == "wrong_side" else "Could not find nutrition or ingredient information.")
280
- return JSONResponse({"error": "no_label", "message": msg, "tip": tip,
281
- "front_words_found": label_check.get("front_hits", [])})
282
-
283
- # Allergen check
284
- allergen_warning = ""
285
- owner_id = user["id"] if user else None
286
- try:
287
- with db_conn() as conn:
288
- row = conn.execute(
289
- "SELECT allergens,conditions FROM allergen_profiles WHERE device_key=?",
290
- (device_key,)
291
- ).fetchone()
292
- if row:
293
- tl = extracted_text.lower()
294
- triggered = [a for a in json.loads(row["allergens"] or "[]") if a.lower() in tl] + \
295
- [c for c in json.loads(row["conditions"] or "[]") if c.lower() in tl]
296
- if triggered:
297
- allergen_warning = f"⚠️ ALLERGEN ALERT — may contain: {', '.join(triggered)}"
298
- except Exception as exc:
299
- logger.warning("Allergen check: %s", exc)
300
-
301
- # Web search (non-blocking)
302
- web_context = await asyncio.to_thread(
303
- _get_live_search, f"health analysis ingredients {extracted_text[:120]}")
304
-
305
- # LLM analysis
306
- result = await analyse_label(
307
- extracted_text, persona, age_group, product_category,
308
- language, web_context, blur_info, label_check.get("confidence", "medium")
309
- )
310
- result["allergen_warning"] = allergen_warning
311
- result["blur_info"] = blur_info
312
- result["scan_meta"] = scan_check
313
-
314
- # Auto-log to daily tracker
315
- today = datetime.date.today().isoformat()
316
- nutr = {n["name"].lower(): float(n.get("value", 0))
317
- for n in result.get("nutrient_breakdown", [])
318
- if isinstance(n.get("value"), (int, float))}
319
- cal = nutr.get("energy", nutr.get("calories", nutr.get("calorie", 0)))
320
- prot = nutr.get("protein", 0)
321
- carb = nutr.get("carbohydrate", nutr.get("carbs", 0))
322
- fat = nutr.get("fat", 0)
323
- sod = nutr.get("sodium", 0)
324
- fib = nutr.get("fiber", nutr.get("fibre", 0))
325
- sug = nutr.get("sugar", nutr.get("sugars", 0))
326
-
327
- with db_conn() as conn:
328
- conn.execute(
329
- """INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,
330
- calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
331
- (owner_id, device_key, today, result.get("product_name","Scanned item"),
332
- cal, prot, carb, fat, sod, fib, sug, "scan")
333
- )
334
- conn.execute(
335
- """INSERT INTO scans(user_id,device_key,product_name,score,verdict,
336
- calories,protein,carbs,fat,sodium,fiber,sugar,persona,language,analysis_json)
337
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
338
- (owner_id, device_key, result.get("product_name","Unknown"),
339
- result.get("score", 0), result.get("verdict", ""),
340
- cal, prot, carb, fat, sod, fib, sug, persona, language,
341
- json.dumps({k: v for k, v in result.items()
342
- if k not in ("blur_info","scan_meta","allergen_warning")}))
343
- )
344
-
345
- # Phase 2: populate proprietary food database
346
- try:
347
- upsert_food_product(
348
- name=result.get("product_name", ""),
349
- nutrients=result.get("nutrient_breakdown", []),
350
- score=result.get("score", 0),
351
- ingredients_raw=extracted_text,
352
- category=result.get("product_category", ""),
353
- source="llm_scan",
354
- )
355
- except Exception as exc:
356
- logger.warning("Food DB upsert: %s", exc)
357
-
358
- # Update streak
359
- if user:
360
- update_streak_user(user["id"])
361
- else:
362
- # Legacy device streak
363
- from app.services.auth import update_streak_user as _upd
364
- try:
365
- _ensure_device(device_key)
366
- today_d = datetime.date.today().isoformat()
367
- yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
368
- with db_conn() as conn:
369
- row = conn.execute(
370
- "SELECT streak_days, last_scan_date FROM devices WHERE device_key=?",
371
- (device_key,)
372
- ).fetchone()
373
- if row and row["last_scan_date"] != today_d:
374
- streak = (row["streak_days"] + 1) if row["last_scan_date"] == yesterday else 1
375
- conn.execute(
376
- "UPDATE devices SET streak_days=?, last_scan_date=? WHERE device_key=?",
377
- (streak, today_d, device_key)
378
- )
379
- except Exception:
380
- pass
381
-
382
- return JSONResponse(result)
383
-
384
- except ValueError as exc:
385
- return JSONResponse({"error": str(exc)}, status_code=400)
386
- except Exception as exc:
387
- logger.error("Analysis error: %s", exc, exc_info=True)
388
- return JSONResponse({"error": f"Scan failed: {str(exc)[:140]}. Please try again."})
389
-
390
-
391
- @app.get("/scan-status")
392
- async def scan_status(request: Request):
393
- user = _get_request_user(request)
394
- device_key = _device_key(request)
395
- _ensure_device(device_key)
396
- month_key = datetime.date.today().isoformat()[:7]
397
-
398
- if user:
399
- from app.models.db import db_conn as _db
400
- with _db() as conn:
401
- row = conn.execute(
402
- "SELECT is_pro, scan_month, scan_count_month, streak_days FROM users WHERE id=?",
403
- (user["id"],)
404
- ).fetchone()
405
- if not row or row["scan_month"] != month_key:
406
- return {"scans_used": 0, "scans_remaining": FREE_SCAN_LIMIT,
407
- "is_pro": False, "limit": FREE_SCAN_LIMIT, "streak": 0}
408
- used = row["scan_count_month"]
409
- return {"scans_used": used,
410
- "scans_remaining": 9999 if row["is_pro"] else max(0, FREE_SCAN_LIMIT - used),
411
- "is_pro": bool(row["is_pro"]), "limit": FREE_SCAN_LIMIT,
412
- "streak": row["streak_days"],
413
- "authenticated": True}
414
-
415
- with db_conn() as conn:
416
- row = conn.execute(
417
- "SELECT is_pro, month, scan_count, streak_days FROM devices WHERE device_key=?",
418
- (device_key,)
419
- ).fetchone()
420
- if not row or row["month"] != month_key:
421
- return {"scans_used": 0, "scans_remaining": FREE_SCAN_LIMIT,
422
- "is_pro": False, "limit": FREE_SCAN_LIMIT, "streak": 0, "authenticated": False}
423
- used = row["scan_count"]
424
- return {"scans_used": used,
425
- "scans_remaining": 9999 if row["is_pro"] else max(0, FREE_SCAN_LIMIT - used),
426
- "is_pro": bool(row["is_pro"]), "limit": FREE_SCAN_LIMIT,
427
- "streak": row["streak_days"], "authenticated": False}
428
-
429
-
430
- @app.post("/activate-pro")
431
- async def activate_pro_legacy(request: Request, payment_id: str = Form(...)):
432
- """
433
- LEGACY endpoint kept for backward compat.
434
- Real payment flow: POST /payments/create-order → Razorpay modal → POST /payments/verify
435
- """
436
- if payment_id.startswith("demo_"):
437
- logger.warning("DEMO payment ID used — no real money charged")
438
- device_key = _device_key(request)
439
- _ensure_device(device_key)
440
- with db_conn() as conn:
441
- conn.execute("UPDATE devices SET is_pro=1 WHERE device_key=?", (device_key,))
442
- return {"status": "activated",
443
- "message": "Pro activated. NOTE: Use /payments/create-order for real billing.",
444
- "real_billing_endpoint": "/payments/create-order"}
445
-
446
-
447
- @app.post("/onboarding-complete")
448
- async def onboarding_complete(
449
- request : Request,
450
- persona : str = Form("General Adult"),
451
- language : str = Form("en"),
452
- tdee : float = Form(0),
453
- allergens: str = Form("[]"),
454
- ):
455
- user = _get_request_user(request)
456
- device_key = _device_key(request)
457
- _ensure_device(device_key)
458
- user_id = user["id"] if user else None
459
- with db_conn() as conn:
460
- conn.execute("UPDATE devices SET onboarding_done=1,persona=?,language=?,tdee=? WHERE device_key=?",
461
- (persona, language, tdee, device_key))
462
- if user_id:
463
- conn.execute("UPDATE users SET onboarding_done=1,persona=?,language=?,tdee=? WHERE id=?",
464
- (persona, language, tdee, user_id))
465
- conn.execute(
466
- "INSERT OR REPLACE INTO allergen_profiles(device_key,user_id,allergens) VALUES(?,?,?)",
467
- (device_key, user_id, allergens)
468
- )
469
- return {"status": "ok"}
470
-
471
-
472
- @app.get("/daily-summary")
473
- async def daily_summary(request: Request, date: str = None):
474
- user = _get_request_user(request)
475
- device_key = _device_key(request)
476
- target_date = date or datetime.date.today().isoformat()
477
-
478
- user_id = user["id"] if user else None
479
- with db_conn() as conn:
480
- if user_id:
481
- dev = conn.execute("SELECT tdee FROM users WHERE id=?", (user_id,)).fetchone()
482
- else:
483
- dev = conn.execute("SELECT tdee FROM devices WHERE device_key=?", (device_key,)).fetchone()
484
-
485
- # Fetch from either user_id or device_key
486
- clause = "user_id=?" if user_id else "device_key=?"
487
- param = user_id or device_key
488
- row = conn.execute(
489
- f"""SELECT SUM(calories) cal, SUM(protein) prot, SUM(carbs) carb,
490
- SUM(fat) fat, SUM(sodium) sod, SUM(fiber) fib, SUM(sugar) sug, COUNT(*) items
491
- FROM daily_logs WHERE {clause} AND log_date=?""",
492
- (param, target_date)
493
- ).fetchone()
494
- log_items = conn.execute(
495
- f"""SELECT id, meal_name, calories, protein, carbs, fat, sodium, source, logged_at
496
- FROM daily_logs WHERE {clause} AND log_date=? ORDER BY logged_at DESC""",
497
- (param, target_date)
498
- ).fetchall()
499
-
500
- tdee = float((dev and dev["tdee"]) or 2000) or 2000
501
- totals = {k: round(row[k] or 0, 1)
502
- for k in ("cal","prot","carb","fat","sod","fib","sug")}
503
- t = {"calories": round(tdee), "protein": 56,
504
- "carbs": round(tdee*.5/4), "fat": round(tdee*.3/9),
505
- "sodium": 2300, "fiber": 28, "sugar": 50}
506
- pct = {k: min(100, round(totals.get(k, 0) / v * 100)) if v else 0
507
- for k, v in (("cal","calories"),("prot","protein"),
508
- ("carb","carbs"),("fat","fat"))}
509
-
510
- cal_left = max(0, t["calories"] - totals["cal"])
511
- prot_left = max(0, t["protein"] - totals["prot"])
512
- suggestion = ""
513
- if cal_left < 200: suggestion = "🎯 Almost at your calorie target — great tracking!"
514
- elif prot_left > 20: suggestion = f"💪 {round(prot_left)}g protein left. Try: eggs, dal, paneer."
515
- elif cal_left > 600: suggestion = f"🍽 {round(cal_left)} kcal remaining — a balanced meal fits well."
516
-
517
- return {"date": target_date, "totals": totals, "targets": t, "pct": pct,
518
- "suggestion": suggestion, "items": row["items"] or 0,
519
- "log": [dict(r) for r in log_items]}
520
-
521
-
522
- @app.post("/daily-log")
523
- @limiter.limit("30/minute")
524
- async def daily_log(
525
- request : Request,
526
- meal_name: str = Form(...),
527
- calories : float = Form(0),
528
- protein : float = Form(0),
529
- carbs : float = Form(0),
530
- fat : float = Form(0),
531
- sodium : float = Form(0),
532
- fiber : float = Form(0),
533
- sugar : float = Form(0),
534
- source : str = Form("manual"),
535
- log_date : str = Form(None),
536
- ):
537
- user = _get_request_user(request)
538
- device_key = _device_key(request)
539
- target_date = log_date or datetime.date.today().isoformat()
540
- user_id = user["id"] if user else None
541
- _ensure_device(device_key)
542
- with db_conn() as conn:
543
- conn.execute(
544
- "INSERT INTO daily_logs(user_id,device_key,log_date,meal_name,calories,protein,carbs,fat,sodium,fiber,sugar,source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
545
- (user_id, device_key, target_date, meal_name, calories, protein, carbs, fat, sodium, fiber, sugar, source)
546
- )
547
- return {"status": "logged", "date": target_date, "meal": meal_name}
548
-
549
-
550
- @app.delete("/daily-log/{log_id}")
551
- async def delete_log(request: Request, log_id: int):
552
- device_key = _device_key(request)
553
- with db_conn() as conn:
554
- conn.execute("DELETE FROM daily_logs WHERE id=? AND device_key=?", (log_id, device_key))
555
- return {"status": "deleted", "id": log_id}
556
-
557
-
558
- @app.get("/allergen-profile")
559
- async def get_allergen_profile(request: Request):
560
- device_key = _device_key(request)
561
- with db_conn() as conn:
562
- row = conn.execute(
563
- "SELECT allergens, conditions FROM allergen_profiles WHERE device_key=?", (device_key,)
564
- ).fetchone()
565
- if not row:
566
- return {"allergens": [], "conditions": []}
567
- return {"allergens": json.loads(row["allergens"] or "[]"),
568
- "conditions": json.loads(row["conditions"] or "[]")}
569
-
570
-
571
- @app.post("/allergen-profile")
572
- async def set_allergen_profile(request: Request, allergens: str = Form("[]"),
573
- conditions: str = Form("[]")):
574
- device_key = _device_key(request)
575
- _ensure_device(device_key)
576
- with db_conn() as conn:
577
- conn.execute(
578
- "INSERT OR REPLACE INTO allergen_profiles(device_key,allergens,conditions,updated_at) VALUES(?,?,?,datetime('now'))",
579
- (device_key, allergens, conditions)
580
- )
581
- return {"status": "saved"}
582
-
583
-
584
- @app.post("/nps")
585
- async def submit_nps(request: Request, score: int = Form(...), comment: str = Form("")):
586
- if not 0 <= score <= 10:
587
- return JSONResponse({"error": "Score must be 0-10"}, status_code=400)
588
- user = _get_request_user(request)
589
- device_key = _device_key(request)
590
- with db_conn() as conn:
591
- conn.execute(
592
- "INSERT INTO nps_responses(device_key,user_id,score,comment) VALUES(?,?,?,?)",
593
- (device_key, user["id"] if user else None, score, comment[:500])
594
- )
595
- return {"status": "thank_you"}
596
-
597
-
598
- @app.get("/admin/analytics")
599
- async def admin_analytics(admin_token: str = ""):
600
- if admin_token != os.environ.get("ADMIN_TOKEN", "changeme"):
601
- raise HTTPException(status_code=403, detail="Invalid token")
602
- today = datetime.date.today().isoformat()
603
- mkey = today[:7]
604
- with db_conn() as conn:
605
- dau = conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE DATE(scanned_at)=?", (today,)).fetchone()[0]
606
- mau = conn.execute("SELECT COUNT(DISTINCT COALESCE(user_id,device_key)) FROM scans WHERE strftime('%Y-%m',scanned_at)=?", (mkey,)).fetchone()[0]
607
- tot = conn.execute("SELECT COUNT(*) FROM scans").fetchone()[0]
608
- avg_s= conn.execute("SELECT AVG(score) FROM scans").fetchone()[0]
609
- avg_n= conn.execute("SELECT AVG(score) FROM nps_responses").fetchone()[0]
610
- npc = conn.execute("SELECT COUNT(*) FROM nps_responses").fetchone()[0]
611
- users= conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
612
- top = conn.execute("SELECT product_name,COUNT(*) c FROM scans GROUP BY product_name ORDER BY c DESC LIMIT 10").fetchall()
613
- food_ct = conn.execute("SELECT COUNT(*) FROM food_products").fetchone()[0]
614
- verified= conn.execute("SELECT COUNT(*) FROM food_products WHERE verified=1").fetchone()[0]
615
- return {"dau": dau, "mau": mau, "total_scans": tot, "total_users": users,
616
- "avg_score": round(avg_s or 0, 2), "avg_nps": round(avg_n or 0, 2),
617
- "nps_count": npc, "dau_mau": round(dau/mau*100,1) if mau else 0,
618
- "food_db": {"total": food_ct, "verified": verified},
619
- "top_products": [{"name": r[0], "scans": r[1]} for r in top]}
620
-
621
-
622
- @app.post("/admin/create-api-key")
623
- async def create_api_key_endpoint(admin_token: str = Form(...),
624
- client_name: str = Form(...),
625
- plan: str = Form("business")):
626
- if admin_token != os.environ.get("ADMIN_TOKEN", "changeme"):
627
- raise HTTPException(status_code=403, detail="Invalid admin token")
628
- key = "eak_" + secrets.token_urlsafe(32)
629
- with db_conn() as conn:
630
- conn.execute("INSERT INTO api_keys(api_key,client_name,plan) VALUES(?,?,?)",
631
- (key, client_name, plan))
632
- return {"api_key": key, "client": client_name, "plan": plan}
633
-
634
-
635
- @app.post("/generate-share-card")
636
- @limiter.limit("20/minute")
637
- async def generate_share_card(
638
- request: Request, product_name: str = Form(...),
639
- score: int = Form(...), verdict: str = Form(...),
640
- top_warning: str = Form(""), top_pro: str = Form(""),
641
- ):
642
- from PIL import Image, ImageDraw, ImageFont
643
- W, H = 1080, 1080
644
- img = Image.new("RGB", (W, H), (15, 17, 23))
645
- draw = ImageDraw.Draw(img)
646
- font = ImageFont.load_default()
647
- s_rgb = (34,197,94) if score>=7 else (245,158,11) if score>=4 else (239,68,68)
648
- def centered(text, y, fill):
649
- try: tw = font.getbbox(text)[2] - font.getbbox(text)[0]
650
- except: tw = len(text) * 6
651
- draw.text(((W-tw)//2, y), text, fill=fill, font=font)
652
- draw.ellipse([340,160,740,560], outline=s_rgb, width=18)
653
- centered(str(score), 340, s_rgb); centered("/10", 430, (100,116,139))
654
- pname = product_name[:38] + ("…" if len(product_name)>38 else "")
655
- centered(pname, 600, (255,255,255)); centered(verdict[:50], 650, (148,163,184))
656
- if top_pro:
657
- draw.rectangle([60,700,1020,760], fill=(15,60,40))
658
- centered(f"✓ {top_pro[:65]}", 718, (74,222,128))
659
- if top_warning:
660
- draw.rectangle([60,775,1020,840], fill=(124,29,29))
661
- centered(f"⚠ {top_warning[:65]}", 795, (252,165,165))
662
- centered("eatlytic.com • scan any food label, no barcode needed", 1000, (71,85,105))
663
- draw.text((40, 1050), MEDICAL_DISCLAIMER[:90], fill=(50,50,60), font=font)
664
- buf = BytesIO(); img.save(buf, format="PNG", optimize=True); buf.seek(0)
665
- return Response(content=buf.getvalue(), media_type="image/png",
666
- headers={"Content-Disposition": "attachment; filename=eatlytic-scan.png"})
667
-
668
-
669
- @app.post("/export-pdf")
670
- @limiter.limit("10/minute")
671
- async def export_pdf(request: Request, analysis_json: str = Form(...)):
672
- try:
673
- data = json.loads(analysis_json)
674
- except Exception:
675
- return JSONResponse({"error": "Invalid JSON"}, status_code=400)
676
- try:
677
- from reportlab.lib.pagesizes import A4
678
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
679
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
680
- from reportlab.lib import colors as rl; from reportlab.lib.units import cm
681
- except ImportError:
682
- return JSONResponse({"error": "reportlab not installed"}, status_code=501)
683
- buf = BytesIO()
684
- doc = SimpleDocTemplate(buf, pagesize=A4, rightMargin=2*cm, leftMargin=2*cm,
685
- topMargin=2*cm, bottomMargin=2*cm)
686
- stys = getSampleStyleSheet(); story = []
687
- story.append(Paragraph("Eatlytic Food Label Analysis", stys["Title"]))
688
- story.append(Paragraph(f"Product: {data.get('product_name','Unknown')}", stys["Heading2"]))
689
- story.append(Paragraph(MEDICAL_DISCLAIMER,
690
- ParagraphStyle("d", parent=stys["Normal"], fontSize=8, textColor=rl.grey)))
691
- story.append(Spacer(1,.4*cm))
692
- score = data.get("score",0); sc = "22c55e" if score>=7 else "f59e0b" if score>=4 else "ef4444"
693
- story.append(Paragraph(f"<font color='#{sc}'>Health Score: {score}/10 — {data.get('verdict','')}</font>", stys["Heading1"]))
694
- if data.get("summary"):
695
- story.append(Paragraph("Summary", stys["Heading2"]))
696
- story.append(Paragraph(data["summary"], stys["Normal"]))
697
- nutrients = data.get("nutrient_breakdown",[])
698
- if nutrients:
699
- story.append(Paragraph("Nutrient Breakdown", stys["Heading2"]))
700
- td = [["Nutrient","Amount","Rating"]] + [
701
- [str(n.get("name","")), f"{n.get('value','')} {n.get('unit','')}".strip(),
702
- str(n.get("rating","")).upper()] for n in nutrients]
703
- tbl = Table(td, colWidths=[6*cm,4*cm,4*cm])
704
- tbl.setStyle(TableStyle([
705
- ("BACKGROUND",(0,0),(-1,0),rl.HexColor("1D9E75")),("TEXTCOLOR",(0,0),(-1,0),rl.white),
706
- ("FONTSIZE",(0,0),(-1,-1),10),("BACKGROUND",(0,1),(-1,-1),rl.HexColor("f8faf8")),
707
- ("GRID",(0,0),(-1,-1),.4,rl.HexColor("d0d8d4")),
708
- ("TOPPADDING",(0,0),(-1,-1),6),("BOTTOMPADDING",(0,0),(-1,-1),6),
709
- ("LEFTPADDING",(0,0),(-1,-1),8),("RIGHTPADDING",(0,0),(-1,-1),8),
710
- ])); story.append(tbl)
711
- if data.get("pros"):
712
- story.append(Paragraph("Benefits", stys["Heading2"]))
713
- [story.append(Paragraph(f"✓ {p}", stys["Normal"])) for p in data["pros"]]
714
- if data.get("cons"):
715
- story.append(Paragraph("Concerns", stys["Heading2"]))
716
- [story.append(Paragraph(f"✗ {c}", stys["Normal"])) for c in data["cons"]]
717
- try:
718
- doc.build(story)
719
- except Exception as exc:
720
- return JSONResponse({"error": f"PDF failed: {exc}"}, status_code=500)
721
- buf.seek(0); safe = data.get("product_name","scan").replace(" ","-")[:40]
722
- return Response(content=buf.getvalue(), media_type="application/pdf",
723
- headers={"Content-Disposition": f"attachment; filename=eatlytic-{safe}.pdf"})