Spaces:
Sleeping
Sleeping
Upload 5 files
Browse files- auth.py +104 -0
- conftest.py +4 -0
- db.py +290 -0
- main.py +723 -0
- payments.py +88 -0
auth.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/routes/auth.py
|
| 3 |
+
Auth endpoints: OTP request, OTP verify, logout, profile.
|
| 4 |
+
Wraps app/services/auth.py logic.
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
from fastapi import APIRouter, Request, Form, HTTPException
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
from app.services.auth import (
|
| 10 |
+
send_email_otp, verify_email_otp,
|
| 11 |
+
create_session, revoke_session, get_user_from_token,
|
| 12 |
+
)
|
| 13 |
+
from app.models.db import db_conn
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.post("/request-otp")
|
| 20 |
+
async def request_otp(email: str = Form(...)):
|
| 21 |
+
"""Send a 6-digit OTP to the user's email. Dev mode: returns OTP in response."""
|
| 22 |
+
try:
|
| 23 |
+
otp = send_email_otp(email)
|
| 24 |
+
return JSONResponse({
|
| 25 |
+
"sent" : True,
|
| 26 |
+
"message": "OTP sent. Check your email.",
|
| 27 |
+
# Remove next line in production — only for dev/HF testing
|
| 28 |
+
"_dev_otp": otp,
|
| 29 |
+
})
|
| 30 |
+
except Exception as exc:
|
| 31 |
+
logger.error("OTP request failed: %s", exc)
|
| 32 |
+
raise HTTPException(status_code=500, detail="Could not send OTP")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("/verify-otp")
|
| 36 |
+
async def verify_otp(
|
| 37 |
+
request: Request,
|
| 38 |
+
email: str = Form(...),
|
| 39 |
+
otp : str = Form(...),
|
| 40 |
+
):
|
| 41 |
+
"""Verify OTP. Returns session token on success."""
|
| 42 |
+
user = verify_email_otp(email, otp)
|
| 43 |
+
if not user:
|
| 44 |
+
raise HTTPException(status_code=401, detail="Invalid or expired OTP")
|
| 45 |
+
device_hint = request.headers.get("user-agent", "")[:100]
|
| 46 |
+
token = create_session(user["id"], device_hint)
|
| 47 |
+
return JSONResponse({
|
| 48 |
+
"token" : token,
|
| 49 |
+
"user_id" : user["id"],
|
| 50 |
+
"email" : user.get("email", ""),
|
| 51 |
+
"is_pro" : bool(user.get("is_pro", 0)),
|
| 52 |
+
"message" : "Login successful",
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.post("/logout")
|
| 57 |
+
async def logout(request: Request):
|
| 58 |
+
"""Revoke the current session token."""
|
| 59 |
+
auth = request.headers.get("Authorization", "")
|
| 60 |
+
token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
|
| 61 |
+
if token:
|
| 62 |
+
revoke_session(token)
|
| 63 |
+
return JSONResponse({"logged_out": True})
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.get("/me")
|
| 67 |
+
async def get_me(request: Request):
|
| 68 |
+
"""Return current user profile from Bearer token."""
|
| 69 |
+
auth = request.headers.get("Authorization", "")
|
| 70 |
+
token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
|
| 71 |
+
user = get_user_from_token(token) if token else None
|
| 72 |
+
if not user:
|
| 73 |
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
| 74 |
+
return JSONResponse({
|
| 75 |
+
"user_id" : user["id"],
|
| 76 |
+
"email" : user.get("email", ""),
|
| 77 |
+
"name" : user.get("name", ""),
|
| 78 |
+
"is_pro" : bool(user.get("is_pro", 0)),
|
| 79 |
+
"streak_days": user.get("streak_days", 0),
|
| 80 |
+
"persona" : user.get("persona", "General Adult"),
|
| 81 |
+
"language" : user.get("language", "en"),
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.put("/profile")
|
| 86 |
+
async def update_profile(
|
| 87 |
+
request : Request,
|
| 88 |
+
name : str = Form(""),
|
| 89 |
+
persona : str = Form("General Adult"),
|
| 90 |
+
language: str = Form("en"),
|
| 91 |
+
tdee : float = Form(2000),
|
| 92 |
+
):
|
| 93 |
+
"""Update user profile fields."""
|
| 94 |
+
auth = request.headers.get("Authorization", "")
|
| 95 |
+
token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
|
| 96 |
+
user = get_user_from_token(token) if token else None
|
| 97 |
+
if not user:
|
| 98 |
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
| 99 |
+
with db_conn() as conn:
|
| 100 |
+
conn.execute(
|
| 101 |
+
"UPDATE users SET name=?, persona=?, language=?, tdee=? WHERE id=?",
|
| 102 |
+
(name, persona, language, tdee, user["id"])
|
| 103 |
+
)
|
| 104 |
+
return JSONResponse({"updated": True})
|
conftest.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
# Ensure project root is on path so `from app.xxx` works in tests
|
| 4 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
db.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/models/db.py
|
| 3 |
+
Database schema, connection management, and initialisation.
|
| 4 |
+
Uses SQLite with WAL mode for single-server MVP.
|
| 5 |
+
Migration path: swap sqlite3 for asyncpg/SQLAlchemy when ≥100 DAU.
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import sqlite3
|
| 10 |
+
import threading
|
| 11 |
+
import logging
|
| 12 |
+
from contextlib import contextmanager
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
DATA_DIR = os.path.join(os.getcwd(), "data")
|
| 17 |
+
DB_FILE = os.path.join(DATA_DIR, "eatlytic.db")
|
| 18 |
+
os.makedirs(DATA_DIR, exist_ok=True)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_connection() -> sqlite3.Connection:
|
| 22 |
+
conn = sqlite3.connect(DB_FILE, check_same_thread=False, timeout=15)
|
| 23 |
+
conn.row_factory = sqlite3.Row
|
| 24 |
+
conn.execute("PRAGMA journal_mode=WAL")
|
| 25 |
+
conn.execute("PRAGMA foreign_keys=ON")
|
| 26 |
+
conn.execute("PRAGMA synchronous=NORMAL") # fast + safe in WAL mode
|
| 27 |
+
return conn
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@contextmanager
|
| 31 |
+
def db_conn():
|
| 32 |
+
"""Thread-safe context manager: auto-commit on success, rollback on error."""
|
| 33 |
+
conn = get_connection()
|
| 34 |
+
try:
|
| 35 |
+
yield conn
|
| 36 |
+
conn.commit()
|
| 37 |
+
except Exception:
|
| 38 |
+
conn.rollback()
|
| 39 |
+
raise
|
| 40 |
+
finally:
|
| 41 |
+
conn.close()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def init_db() -> None:
|
| 45 |
+
"""Idempotent schema creation. Run at startup."""
|
| 46 |
+
with db_conn() as conn:
|
| 47 |
+
conn.executescript("""
|
| 48 |
+
-- ── USERS (Phase 1: real accounts) ───────────────────────
|
| 49 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 50 |
+
id TEXT PRIMARY KEY, -- UUID
|
| 51 |
+
email TEXT UNIQUE,
|
| 52 |
+
phone TEXT UNIQUE,
|
| 53 |
+
name TEXT DEFAULT '',
|
| 54 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 55 |
+
last_login TEXT DEFAULT (datetime('now')),
|
| 56 |
+
is_pro INTEGER DEFAULT 0,
|
| 57 |
+
pro_expires TEXT DEFAULT NULL,
|
| 58 |
+
stripe_customer_id TEXT DEFAULT NULL, -- Razorpay customer
|
| 59 |
+
scan_count_month INTEGER DEFAULT 0,
|
| 60 |
+
scan_month TEXT DEFAULT '',
|
| 61 |
+
streak_days INTEGER DEFAULT 0,
|
| 62 |
+
last_scan_date TEXT DEFAULT '',
|
| 63 |
+
tdee REAL DEFAULT 0,
|
| 64 |
+
persona TEXT DEFAULT 'General Adult',
|
| 65 |
+
language TEXT DEFAULT 'en',
|
| 66 |
+
onboarding_done INTEGER DEFAULT 0
|
| 67 |
+
);
|
| 68 |
+
|
| 69 |
+
-- ── SESSIONS / TOKENS ─────────────────────────────────────
|
| 70 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 71 |
+
token TEXT PRIMARY KEY,
|
| 72 |
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
| 73 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 74 |
+
expires_at TEXT NOT NULL,
|
| 75 |
+
device_hint TEXT DEFAULT ''
|
| 76 |
+
);
|
| 77 |
+
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
| 78 |
+
|
| 79 |
+
-- ── LEGACY DEVICE KEYS (for anonymous users) ──────────────
|
| 80 |
+
CREATE TABLE IF NOT EXISTS devices (
|
| 81 |
+
device_key TEXT PRIMARY KEY,
|
| 82 |
+
user_id TEXT REFERENCES users(id), -- NULL = anonymous
|
| 83 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 84 |
+
is_pro INTEGER DEFAULT 0,
|
| 85 |
+
month TEXT DEFAULT '',
|
| 86 |
+
scan_count INTEGER DEFAULT 0,
|
| 87 |
+
streak_days INTEGER DEFAULT 0,
|
| 88 |
+
last_scan_date TEXT DEFAULT '',
|
| 89 |
+
persona TEXT DEFAULT 'General Adult',
|
| 90 |
+
language TEXT DEFAULT 'en',
|
| 91 |
+
tdee REAL DEFAULT 0,
|
| 92 |
+
onboarding_done INTEGER DEFAULT 0
|
| 93 |
+
);
|
| 94 |
+
|
| 95 |
+
-- ── SCANS ─────────────────────────────────────────────────
|
| 96 |
+
CREATE TABLE IF NOT EXISTS scans (
|
| 97 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 98 |
+
user_id TEXT REFERENCES users(id),
|
| 99 |
+
device_key TEXT,
|
| 100 |
+
product_name TEXT DEFAULT 'Unknown',
|
| 101 |
+
score INTEGER DEFAULT 0,
|
| 102 |
+
verdict TEXT DEFAULT '',
|
| 103 |
+
calories REAL DEFAULT 0,
|
| 104 |
+
protein REAL DEFAULT 0,
|
| 105 |
+
carbs REAL DEFAULT 0,
|
| 106 |
+
fat REAL DEFAULT 0,
|
| 107 |
+
sodium REAL DEFAULT 0,
|
| 108 |
+
fiber REAL DEFAULT 0,
|
| 109 |
+
sugar REAL DEFAULT 0,
|
| 110 |
+
persona TEXT DEFAULT '',
|
| 111 |
+
language TEXT DEFAULT 'en',
|
| 112 |
+
scanned_at TEXT DEFAULT (datetime('now')),
|
| 113 |
+
analysis_json TEXT DEFAULT '{}',
|
| 114 |
+
-- Moat columns: verified data feeds proprietary DB
|
| 115 |
+
verified INTEGER DEFAULT 0,
|
| 116 |
+
verified_by TEXT DEFAULT NULL,
|
| 117 |
+
verified_at TEXT DEFAULT NULL,
|
| 118 |
+
barcode TEXT DEFAULT NULL,
|
| 119 |
+
brand TEXT DEFAULT NULL,
|
| 120 |
+
category TEXT DEFAULT NULL
|
| 121 |
+
);
|
| 122 |
+
CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id);
|
| 123 |
+
CREATE INDEX IF NOT EXISTS idx_scans_device ON scans(device_key);
|
| 124 |
+
CREATE INDEX IF NOT EXISTS idx_scans_date ON scans(scanned_at);
|
| 125 |
+
CREATE INDEX IF NOT EXISTS idx_scans_product ON scans(product_name);
|
| 126 |
+
|
| 127 |
+
-- ── DAILY LOGS ────────────────────────────────────────────
|
| 128 |
+
CREATE TABLE IF NOT EXISTS daily_logs (
|
| 129 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 130 |
+
user_id TEXT REFERENCES users(id),
|
| 131 |
+
device_key TEXT,
|
| 132 |
+
log_date TEXT NOT NULL,
|
| 133 |
+
meal_name TEXT DEFAULT '',
|
| 134 |
+
calories REAL DEFAULT 0,
|
| 135 |
+
protein REAL DEFAULT 0,
|
| 136 |
+
carbs REAL DEFAULT 0,
|
| 137 |
+
fat REAL DEFAULT 0,
|
| 138 |
+
sodium REAL DEFAULT 0,
|
| 139 |
+
fiber REAL DEFAULT 0,
|
| 140 |
+
sugar REAL DEFAULT 0,
|
| 141 |
+
source TEXT DEFAULT 'scan', -- scan | manual | search
|
| 142 |
+
logged_at TEXT DEFAULT (datetime('now'))
|
| 143 |
+
);
|
| 144 |
+
CREATE INDEX IF NOT EXISTS idx_daily_user_date ON daily_logs(user_id, log_date);
|
| 145 |
+
CREATE INDEX IF NOT EXISTS idx_daily_dev_date ON daily_logs(device_key, log_date);
|
| 146 |
+
|
| 147 |
+
-- ── ALLERGEN PROFILES ─────────────────────────────────────
|
| 148 |
+
CREATE TABLE IF NOT EXISTS allergen_profiles (
|
| 149 |
+
device_key TEXT PRIMARY KEY,
|
| 150 |
+
user_id TEXT REFERENCES users(id),
|
| 151 |
+
allergens TEXT DEFAULT '[]',
|
| 152 |
+
conditions TEXT DEFAULT '[]',
|
| 153 |
+
updated_at TEXT DEFAULT (datetime('now'))
|
| 154 |
+
);
|
| 155 |
+
|
| 156 |
+
-- ── PROPRIETARY FOOD DATABASE (Phase 2 moat) ──────────────
|
| 157 |
+
-- Every scan feeds this. After 10K entries, it's a data asset.
|
| 158 |
+
CREATE TABLE IF NOT EXISTS food_products (
|
| 159 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 160 |
+
barcode TEXT UNIQUE, -- EAN-13 if available
|
| 161 |
+
name TEXT NOT NULL,
|
| 162 |
+
brand TEXT DEFAULT '',
|
| 163 |
+
category TEXT DEFAULT '',
|
| 164 |
+
-- Verified nutrition per 100g
|
| 165 |
+
calories_100g REAL DEFAULT 0,
|
| 166 |
+
protein_100g REAL DEFAULT 0,
|
| 167 |
+
carbs_100g REAL DEFAULT 0,
|
| 168 |
+
fat_100g REAL DEFAULT 0,
|
| 169 |
+
sodium_100g REAL DEFAULT 0,
|
| 170 |
+
fiber_100g REAL DEFAULT 0,
|
| 171 |
+
sugar_100g REAL DEFAULT 0,
|
| 172 |
+
sat_fat_100g REAL DEFAULT 0,
|
| 173 |
+
-- Eatlytic scoring
|
| 174 |
+
eatlytic_score INTEGER DEFAULT 0,
|
| 175 |
+
fssai_compliant INTEGER DEFAULT 0,
|
| 176 |
+
ingredients_raw TEXT DEFAULT '',
|
| 177 |
+
allergens_json TEXT DEFAULT '[]',
|
| 178 |
+
-- Data provenance
|
| 179 |
+
source TEXT DEFAULT 'llm_scan', -- llm_scan | human_verified | off_import
|
| 180 |
+
scan_count INTEGER DEFAULT 0, -- how many times scanned
|
| 181 |
+
verified INTEGER DEFAULT 0,
|
| 182 |
+
verified_by TEXT DEFAULT NULL,
|
| 183 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 184 |
+
updated_at TEXT DEFAULT (datetime('now'))
|
| 185 |
+
);
|
| 186 |
+
CREATE INDEX IF NOT EXISTS idx_food_barcode ON food_products(barcode);
|
| 187 |
+
CREATE INDEX IF NOT EXISTS idx_food_name ON food_products(name);
|
| 188 |
+
CREATE INDEX IF NOT EXISTS idx_food_brand ON food_products(brand);
|
| 189 |
+
|
| 190 |
+
-- ── ACCURACY BENCHMARKS (Phase 2) ─────────────────────────
|
| 191 |
+
CREATE TABLE IF NOT EXISTS benchmarks (
|
| 192 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 193 |
+
product_name TEXT NOT NULL,
|
| 194 |
+
ground_truth_json TEXT NOT NULL, -- hand-verified nutrition data
|
| 195 |
+
llm_output_json TEXT DEFAULT '{}',
|
| 196 |
+
ocr_text TEXT DEFAULT '',
|
| 197 |
+
f1_score REAL DEFAULT 0,
|
| 198 |
+
score_delta REAL DEFAULT 0, -- LLM score vs verified score
|
| 199 |
+
field_accuracy TEXT DEFAULT '{}', -- per-field accuracy JSON
|
| 200 |
+
tested_at TEXT DEFAULT (datetime('now')),
|
| 201 |
+
model_used TEXT DEFAULT ''
|
| 202 |
+
);
|
| 203 |
+
|
| 204 |
+
-- ── NPS ───────────────────────────────────────────────────
|
| 205 |
+
CREATE TABLE IF NOT EXISTS nps_responses (
|
| 206 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 207 |
+
device_key TEXT,
|
| 208 |
+
user_id TEXT REFERENCES users(id),
|
| 209 |
+
score INTEGER NOT NULL,
|
| 210 |
+
comment TEXT DEFAULT '',
|
| 211 |
+
submitted_at TEXT DEFAULT (datetime('now'))
|
| 212 |
+
);
|
| 213 |
+
|
| 214 |
+
-- ── PAYMENTS ──────────────────────────────────────────────
|
| 215 |
+
CREATE TABLE IF NOT EXISTS payments (
|
| 216 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 217 |
+
user_id TEXT REFERENCES users(id),
|
| 218 |
+
device_key TEXT,
|
| 219 |
+
razorpay_order_id TEXT UNIQUE,
|
| 220 |
+
razorpay_payment_id TEXT UNIQUE,
|
| 221 |
+
razorpay_signature TEXT DEFAULT '',
|
| 222 |
+
amount_paise INTEGER DEFAULT 19900, -- ₹199
|
| 223 |
+
currency TEXT DEFAULT 'INR',
|
| 224 |
+
status TEXT DEFAULT 'created', -- created|paid|failed
|
| 225 |
+
plan TEXT DEFAULT 'pro_monthly',
|
| 226 |
+
created_at TEXT DEFAULT (datetime('now')),
|
| 227 |
+
paid_at TEXT DEFAULT NULL
|
| 228 |
+
);
|
| 229 |
+
|
| 230 |
+
-- ── B2B API KEYS ──────────────────────────────────────────
|
| 231 |
+
CREATE TABLE IF NOT EXISTS api_keys (
|
| 232 |
+
api_key TEXT PRIMARY KEY,
|
| 233 |
+
client_name TEXT NOT NULL,
|
| 234 |
+
plan TEXT DEFAULT 'business',
|
| 235 |
+
scans_this_month INTEGER DEFAULT 0,
|
| 236 |
+
month TEXT DEFAULT '',
|
| 237 |
+
active INTEGER DEFAULT 1,
|
| 238 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 239 |
+
);
|
| 240 |
+
|
| 241 |
+
-- ── CACHES ────────────────────────────────────────────────
|
| 242 |
+
CREATE TABLE IF NOT EXISTS ocr_cache (
|
| 243 |
+
cache_key TEXT PRIMARY KEY,
|
| 244 |
+
result_json TEXT NOT NULL,
|
| 245 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 246 |
+
);
|
| 247 |
+
CREATE TABLE IF NOT EXISTS ai_cache (
|
| 248 |
+
cache_key TEXT PRIMARY KEY,
|
| 249 |
+
result_json TEXT NOT NULL,
|
| 250 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 251 |
+
);
|
| 252 |
+
""")
|
| 253 |
+
logger.info("Database ready: %s", DB_FILE)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# ── Cache helpers ──────────────────────────────────────────────────────
|
| 257 |
+
def get_ocr_cache(key: str):
|
| 258 |
+
try:
|
| 259 |
+
with db_conn() as c:
|
| 260 |
+
row = c.execute("SELECT result_json FROM ocr_cache WHERE cache_key=?", (key,)).fetchone()
|
| 261 |
+
return json.loads(row["result_json"]) if row else None
|
| 262 |
+
except Exception:
|
| 263 |
+
return None
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def set_ocr_cache(key: str, value: dict):
|
| 267 |
+
try:
|
| 268 |
+
with db_conn() as c:
|
| 269 |
+
c.execute("INSERT OR REPLACE INTO ocr_cache(cache_key,result_json) VALUES(?,?)",
|
| 270 |
+
(key, json.dumps(value)))
|
| 271 |
+
except Exception as exc:
|
| 272 |
+
logger.warning("set_ocr_cache: %s", exc)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def get_ai_cache(key: str):
|
| 276 |
+
try:
|
| 277 |
+
with db_conn() as c:
|
| 278 |
+
row = c.execute("SELECT result_json FROM ai_cache WHERE cache_key=?", (key,)).fetchone()
|
| 279 |
+
return json.loads(row["result_json"]) if row else None
|
| 280 |
+
except Exception:
|
| 281 |
+
return None
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def set_ai_cache(key: str, value: dict):
|
| 285 |
+
try:
|
| 286 |
+
with db_conn() as c:
|
| 287 |
+
c.execute("INSERT OR REPLACE INTO ai_cache(cache_key,result_json) VALUES(?,?)",
|
| 288 |
+
(key, json.dumps(value)))
|
| 289 |
+
except Exception as exc:
|
| 290 |
+
logger.warning("set_ai_cache: %s", exc)
|
main.py
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"})
|
payments.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/routes/payments.py
|
| 3 |
+
Payment endpoints: create Razorpay order, verify payment, status.
|
| 4 |
+
Wraps app/services/payments.py logic.
|
| 5 |
+
"""
|
| 6 |
+
import logging
|
| 7 |
+
from fastapi import APIRouter, Request, Form, HTTPException
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
from app.services.auth import get_user_from_token
|
| 10 |
+
from app.models.db import db_conn
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
router = APIRouter(prefix="/payments", tags=["payments"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _get_user_or_device(request: Request) -> tuple:
|
| 17 |
+
"""Returns (user_id, device_key). Either may be None."""
|
| 18 |
+
auth = request.headers.get("Authorization", "")
|
| 19 |
+
token = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
|
| 20 |
+
user = get_user_from_token(token) if token else None
|
| 21 |
+
user_id = user["id"] if user else None
|
| 22 |
+
import hashlib
|
| 23 |
+
ip = request.client.host if request.client else "unknown"
|
| 24 |
+
ua = request.headers.get("user-agent", "")
|
| 25 |
+
device_key = hashlib.md5(f"{ip}:{ua}".encode()).hexdigest()[:16]
|
| 26 |
+
return user_id, device_key
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.post("/create-order")
|
| 30 |
+
async def create_order(request: Request):
|
| 31 |
+
"""
|
| 32 |
+
Create a Razorpay order. Returns order_id + key_id for frontend modal.
|
| 33 |
+
Requires RAZORPAY_KEY_ID + RAZORPAY_KEY_SECRET env vars.
|
| 34 |
+
"""
|
| 35 |
+
user_id, device_key = _get_user_or_device(request)
|
| 36 |
+
if not user_id:
|
| 37 |
+
raise HTTPException(status_code=401,
|
| 38 |
+
detail="Login required to create payment order. POST /auth/request-otp first.")
|
| 39 |
+
try:
|
| 40 |
+
from app.services.payments import create_order as _create
|
| 41 |
+
order = _create(user_id, device_key)
|
| 42 |
+
return JSONResponse(order)
|
| 43 |
+
except RuntimeError as exc:
|
| 44 |
+
raise HTTPException(status_code=503, detail=str(exc))
|
| 45 |
+
except Exception as exc:
|
| 46 |
+
logger.error("create_order failed: %s", exc)
|
| 47 |
+
raise HTTPException(status_code=500, detail="Payment service error")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.post("/verify")
|
| 51 |
+
async def verify_payment(
|
| 52 |
+
request : Request,
|
| 53 |
+
razorpay_order_id : str = Form(...),
|
| 54 |
+
razorpay_payment_id : str = Form(...),
|
| 55 |
+
razorpay_signature : str = Form(...),
|
| 56 |
+
):
|
| 57 |
+
"""
|
| 58 |
+
Called by frontend after Razorpay success callback.
|
| 59 |
+
Verifies HMAC signature and activates Pro.
|
| 60 |
+
"""
|
| 61 |
+
try:
|
| 62 |
+
from app.services.payments import activate_pro_after_payment
|
| 63 |
+
result = activate_pro_after_payment(
|
| 64 |
+
razorpay_order_id, razorpay_payment_id, razorpay_signature
|
| 65 |
+
)
|
| 66 |
+
return JSONResponse(result)
|
| 67 |
+
except ValueError as exc:
|
| 68 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 69 |
+
except RuntimeError as exc:
|
| 70 |
+
raise HTTPException(status_code=503, detail=str(exc))
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
logger.error("verify_payment failed: %s", exc)
|
| 73 |
+
raise HTTPException(status_code=500, detail="Payment verification failed")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/status/{order_id}")
|
| 77 |
+
async def payment_status(request: Request, order_id: str):
|
| 78 |
+
"""Check status of a Razorpay order."""
|
| 79 |
+
try:
|
| 80 |
+
from app.services.payments import get_payment_status
|
| 81 |
+
status = get_payment_status(order_id)
|
| 82 |
+
if not status:
|
| 83 |
+
raise HTTPException(status_code=404, detail="Order not found")
|
| 84 |
+
return JSONResponse(status)
|
| 85 |
+
except HTTPException:
|
| 86 |
+
raise
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
raise HTTPException(status_code=500, detail=str(exc))
|