database.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import sqlite3
  2. import os
  3. import logging
  4. import secrets
  5. from datetime import datetime, timedelta
  6. from typing import Optional, Dict, Any, List
  7. logger = logging.getLogger(__name__)
  8. # Locate db correctly in the same directory
  9. DB_PATH = os.path.join(os.path.dirname(__file__), "localfood.db")
  10. def get_db_connection():
  11. # Enable higher timeout and disable thread checks for FastAPI async compatibility
  12. conn = sqlite3.connect(DB_PATH, timeout=30.0, check_same_thread=False)
  13. conn.row_factory = sqlite3.Row
  14. # Enable Write-Ahead Log (WAL) mode for simultaneous read/write operations
  15. conn.execute('PRAGMA journal_mode=WAL')
  16. conn.execute('PRAGMA synchronous=NORMAL')
  17. return conn
  18. def create_tables():
  19. """Initialize the SQLite database with required tables"""
  20. conn = None
  21. try:
  22. conn = get_db_connection()
  23. cursor = conn.cursor()
  24. # Create users table securely locally
  25. cursor.execute('''
  26. CREATE TABLE IF NOT EXISTS users (
  27. id INTEGER PRIMARY KEY AUTOINCREMENT,
  28. username TEXT UNIQUE NOT NULL,
  29. password_hash TEXT NOT NULL,
  30. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  31. )
  32. ''')
  33. # Create sessions table for database-backed tokens
  34. cursor.execute('''
  35. CREATE TABLE IF NOT EXISTS sessions (
  36. token TEXT PRIMARY KEY,
  37. user_id INTEGER NOT NULL,
  38. expires_at TIMESTAMP NOT NULL,
  39. FOREIGN KEY (user_id) REFERENCES users (id)
  40. )
  41. ''')
  42. # Create localized foods table based on Sprint 5 architecture
  43. cursor.execute('''
  44. CREATE TABLE IF NOT EXISTS foods (
  45. id INTEGER PRIMARY KEY AUTOINCREMENT,
  46. name TEXT NOT NULL,
  47. category TEXT,
  48. base_weight_g REAL DEFAULT 100.0,
  49. calories REAL DEFAULT 0.0,
  50. protein_g REAL DEFAULT 0.0,
  51. fat_g REAL DEFAULT 0.0,
  52. carbs_g REAL DEFAULT 0.0,
  53. fiber_g REAL DEFAULT 0.0,
  54. sugar_g REAL DEFAULT 0.0,
  55. sodium_mg REAL DEFAULT 0.0,
  56. vitamin_a_iu REAL DEFAULT 0.0,
  57. vitamin_c_mg REAL DEFAULT 0.0,
  58. calcium_mg REAL DEFAULT 0.0,
  59. iron_mg REAL DEFAULT 0.0,
  60. potassium_mg REAL DEFAULT 0.0,
  61. cholesterol_mg REAL DEFAULT 0.0,
  62. source TEXT DEFAULT 'System'
  63. )
  64. ''')
  65. # Create chat history table for Sprint 6 persistence
  66. cursor.execute('''
  67. CREATE TABLE IF NOT EXISTS chat_messages (
  68. id INTEGER PRIMARY KEY AUTOINCREMENT,
  69. user_id INTEGER NOT NULL,
  70. role TEXT NOT NULL,
  71. content TEXT NOT NULL,
  72. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  73. FOREIGN KEY (user_id) REFERENCES users (id)
  74. )
  75. ''')
  76. # Create minimal user_profiles table for macro targets (US-07)
  77. cursor.execute('''
  78. CREATE TABLE IF NOT EXISTS user_profiles (
  79. user_id INTEGER PRIMARY KEY,
  80. target_calories INTEGER DEFAULT 2000,
  81. target_protein_g INTEGER DEFAULT 150,
  82. target_carbs_g INTEGER DEFAULT 200,
  83. target_fat_g INTEGER DEFAULT 65,
  84. FOREIGN KEY (user_id) REFERENCES users (id)
  85. )
  86. ''')
  87. # Create user-named meals table for Sprint 8
  88. cursor.execute('''
  89. CREATE TABLE IF NOT EXISTS saved_meals (
  90. id INTEGER PRIMARY KEY AUTOINCREMENT,
  91. user_id INTEGER NOT NULL,
  92. name TEXT NOT NULL,
  93. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  94. FOREIGN KEY (user_id) REFERENCES users (id)
  95. )
  96. ''')
  97. # Create meal items table to link multiple foods to a single saved meal
  98. cursor.execute('''
  99. CREATE TABLE IF NOT EXISTS meal_items (
  100. id INTEGER PRIMARY KEY AUTOINCREMENT,
  101. meal_id INTEGER NOT NULL,
  102. food_id INTEGER NOT NULL,
  103. amount_g REAL NOT NULL,
  104. FOREIGN KEY (meal_id) REFERENCES saved_meals (id) ON DELETE CASCADE,
  105. FOREIGN KEY (food_id) REFERENCES foods (id)
  106. )
  107. ''')
  108. # Create index for rapid fuzzy search compatibility
  109. cursor.execute('CREATE INDEX IF NOT EXISTS idx_food_name ON foods(name COLLATE NOCASE)')
  110. cursor.execute('CREATE INDEX IF NOT EXISTS idx_saved_meals_user ON saved_meals(user_id)')
  111. conn.commit()
  112. logger.info("Database and tables initialized successfully.")
  113. except Exception as e:
  114. logger.error(f"Error initializing database: {e}")
  115. raise
  116. finally:
  117. if conn:
  118. conn.close()
  119. def save_user_meal(user_id: int, name: str, items: List[Dict[str, Any]]) -> Optional[int]:
  120. """Persist a collection of food items as a named meal list for a user"""
  121. conn = None
  122. try:
  123. conn = get_db_connection()
  124. cursor = conn.cursor()
  125. # 1. Create the meal header
  126. cursor.execute(
  127. "INSERT INTO saved_meals (user_id, name) VALUES (?, ?)",
  128. (user_id, name)
  129. )
  130. meal_id = cursor.lastrowid
  131. # 2. Add each item linked to this meal
  132. for item in items:
  133. cursor.execute(
  134. "INSERT INTO meal_items (meal_id, food_id, amount_g) VALUES (?, ?, ?)",
  135. (meal_id, item['food_id'], item['amount_g'])
  136. )
  137. conn.commit()
  138. return meal_id
  139. except Exception as e:
  140. logger.error(f"Error saving user meal: {e}")
  141. if conn: conn.rollback()
  142. return None
  143. finally:
  144. if conn: conn.close()
  145. def update_user_meal(user_id: int, meal_id: int, name: str, items: Optional[List[Dict[str, Any]]] = None) -> bool:
  146. """Update a user's saved meal (name and optionally items), verifying ownership."""
  147. conn = None
  148. try:
  149. conn = get_db_connection()
  150. cursor = conn.cursor()
  151. # Verify ownership first
  152. cursor.execute("SELECT id FROM saved_meals WHERE id = ? AND user_id = ?", (meal_id, user_id))
  153. if not cursor.fetchone():
  154. return False
  155. cursor.execute(
  156. "UPDATE saved_meals SET name = ? WHERE id = ? AND user_id = ?",
  157. (name, meal_id, user_id)
  158. )
  159. if items is not None:
  160. # Delete old items
  161. cursor.execute("DELETE FROM meal_items WHERE meal_id = ?", (meal_id,))
  162. # Insert new items
  163. for item in items:
  164. cursor.execute(
  165. "INSERT INTO meal_items (meal_id, food_id, amount_g) VALUES (?, ?, ?)",
  166. (meal_id, item['food_id'], item['amount_g'])
  167. )
  168. conn.commit()
  169. return True
  170. except Exception as e:
  171. logger.error(f"Error updating user meal: {e}")
  172. if conn: conn.rollback()
  173. return False
  174. finally:
  175. if conn: conn.close()
  176. def delete_user_meal(user_id: int, meal_id: int) -> bool:
  177. """Delete a user's saved meal and its items, verifying ownership."""
  178. conn = None
  179. try:
  180. conn = get_db_connection()
  181. cursor = conn.cursor()
  182. # Verify ownership and get the meal ID
  183. cursor.execute("SELECT id FROM saved_meals WHERE id = ? AND user_id = ?", (meal_id, user_id))
  184. if not cursor.fetchone():
  185. return False
  186. # Manually delete items first to handle missing PRAGMA foreign_keys
  187. cursor.execute("DELETE FROM meal_items WHERE meal_id = ?", (meal_id,))
  188. cursor.execute("DELETE FROM saved_meals WHERE id = ? AND user_id = ?", (meal_id, user_id))
  189. success = cursor.rowcount > 0
  190. conn.commit()
  191. return success
  192. except Exception as e:
  193. logger.error(f"Error deleting user meal: {e}")
  194. if conn: conn.rollback()
  195. return False
  196. finally:
  197. if conn: conn.close()
  198. def get_user_meals(user_id: int) -> List[Dict[str, Any]]:
  199. """Retrieve all saved meals for a user, including total nutritional calculations"""
  200. conn = None
  201. try:
  202. conn = get_db_connection()
  203. cursor = conn.cursor()
  204. # 1. Get all meals for this user
  205. cursor.execute("SELECT id, name, created_at FROM saved_meals WHERE user_id = ? ORDER BY created_at DESC", (user_id,))
  206. meals = [dict(row) for row in cursor.fetchall()]
  207. # 2. For each meal, get the items and calculate totals
  208. for meal in meals:
  209. cursor.execute('''
  210. SELECT mi.food_id as id, f.name, mi.amount_g, f.calories, f.protein_g, f.fat_g, f.carbs_g
  211. FROM meal_items mi
  212. JOIN foods f ON mi.food_id = f.id
  213. WHERE mi.meal_id = ?
  214. ''', (meal['id'],))
  215. items = cursor.fetchall()
  216. # Add detailed items to the dictionary for the frontend
  217. meal['items'] = [dict(item) for item in items]
  218. # Calculate totals based on weight (database stores per 100g)
  219. total_cals = 0.0
  220. total_protein = 0.0
  221. total_fat = 0.0
  222. total_carbs = 0.0
  223. for item in items:
  224. ratio = item['amount_g'] / 100.0
  225. total_cals += item['calories'] * ratio
  226. total_protein += item['protein_g'] * ratio
  227. total_fat += item['fat_g'] * ratio
  228. total_carbs += item['carbs_g'] * ratio
  229. meal['total_calories'] = round(total_cals, 1)
  230. meal['total_protein_g'] = round(total_protein, 1)
  231. meal['total_fat_g'] = round(total_fat, 1)
  232. meal['total_carbs_g'] = round(total_carbs, 1)
  233. return meals
  234. except Exception as e:
  235. logger.error(f"Error fetching user meals: {e}")
  236. return []
  237. finally:
  238. if conn: conn.close()
  239. def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
  240. """Retrieve user dictionary if they exist"""
  241. conn = None
  242. try:
  243. conn = get_db_connection()
  244. cursor = conn.cursor()
  245. cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
  246. row = cursor.fetchone()
  247. return dict(row) if row else None
  248. except Exception as e:
  249. logger.error(f"Database error fetching user: {e}")
  250. return None
  251. finally:
  252. if conn: conn.close()
  253. def create_user(username: str, password_hash: str) -> Optional[int]:
  254. """Creates a user securely. Returns user_id if successful, None if username exists."""
  255. conn = None
  256. try:
  257. conn = get_db_connection()
  258. cursor = conn.cursor()
  259. cursor.execute(
  260. "INSERT INTO users (username, password_hash) VALUES (?, ?)",
  261. (username, password_hash)
  262. )
  263. user_id = cursor.lastrowid
  264. conn.commit()
  265. return user_id
  266. except sqlite3.IntegrityError:
  267. return None
  268. except Exception as e:
  269. logger.error(f"Database error during user creation: {e}")
  270. raise
  271. finally:
  272. if conn: conn.close()
  273. def create_session(user_id: int) -> str:
  274. """Create a secure 32-character session token in the DB valid for 7 days"""
  275. token = secrets.token_urlsafe(32)
  276. expires_at = datetime.now() + timedelta(days=7)
  277. conn = None
  278. try:
  279. conn = get_db_connection()
  280. cursor = conn.cursor()
  281. cursor.execute(
  282. "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
  283. (token, user_id, expires_at)
  284. )
  285. conn.commit()
  286. return token
  287. except Exception as e:
  288. logger.error(f"Error creating session: {e}")
  289. raise
  290. finally:
  291. if conn: conn.close()
  292. def get_user_from_token(token: str) -> Optional[Dict[str, Any]]:
  293. """Verify a session token and return the associated user data if valid and not expired"""
  294. conn = None
  295. try:
  296. conn = get_db_connection()
  297. cursor = conn.cursor()
  298. # Find user if token exists and hasn't expired
  299. cursor.execute('''
  300. SELECT users.* FROM users
  301. JOIN sessions ON users.id = sessions.user_id
  302. WHERE sessions.token = ? AND sessions.expires_at > ?
  303. ''', (token, datetime.now()))
  304. row = cursor.fetchone()
  305. return dict(row) if row else None
  306. except Exception as e:
  307. logger.error(f"Database error verifying token: {e}")
  308. return None
  309. finally:
  310. if conn: conn.close()
  311. def delete_session(token: str):
  312. """Securely remove a session token when the user logs out"""
  313. conn = None
  314. try:
  315. conn = get_db_connection()
  316. cursor = conn.cursor()
  317. cursor.execute("DELETE FROM sessions WHERE token = ?", (token,))
  318. conn.commit()
  319. except Exception as e:
  320. logger.error(f"Error deleting session: {e}")
  321. finally:
  322. if conn: conn.close()
  323. def search_foods_by_name(query: str, limit: int = 15) -> list[Dict[str, Any]]:
  324. """Securely search for foods matching a string query with relevance-based ordering"""
  325. conn = None
  326. try:
  327. conn = get_db_connection()
  328. cursor = conn.cursor()
  329. # SQL Injection safe query utilizing LIKE parameterization
  330. # We prioritize:
  331. # 1. Items NOT in 'Baby Foods'
  332. # 2. Shorter names (usually more fundamental ingredients)
  333. # 3. Alphabetical order as a tie-breaker
  334. q = f"%{query}%"
  335. prefix_match = f"{query}%"
  336. cursor.execute('''
  337. SELECT * FROM foods
  338. WHERE name LIKE ?
  339. ORDER BY
  340. CASE WHEN category = 'Baby Foods' THEN 1 ELSE 0 END,
  341. CASE WHEN name LIKE ? THEN 0 ELSE 1 END,
  342. LENGTH(name) ASC,
  343. name ASC
  344. LIMIT ?
  345. ''', (q, prefix_match, limit))
  346. rows = cursor.fetchall()
  347. return [dict(row) for row in rows]
  348. except Exception as e:
  349. logger.error(f"Error searching foods: {e}")
  350. return []
  351. finally:
  352. if conn: conn.close()
  353. def save_chat_message(user_id: int, role: str, content: str):
  354. """Persist a chat message to the database"""
  355. conn = None
  356. try:
  357. conn = get_db_connection()
  358. cursor = conn.cursor()
  359. cursor.execute(
  360. "INSERT INTO chat_messages (user_id, role, content) VALUES (?, ?, ?)",
  361. (user_id, role, content)
  362. )
  363. conn.commit()
  364. except Exception as e:
  365. logger.error(f"Error saving chat message: {e}")
  366. finally:
  367. if conn: conn.close()
  368. def get_user_chat_history(user_id: int, limit: int = 50) -> list[Dict[str, Any]]:
  369. """Retrieve the most recent chat messages for a user"""
  370. conn = None
  371. try:
  372. conn = get_db_connection()
  373. cursor = conn.cursor()
  374. # Order by created_at DESC to get recent ones, then reverse for display
  375. cursor.execute('''
  376. SELECT role, content FROM chat_messages
  377. WHERE user_id = ?
  378. ORDER BY created_at DESC
  379. LIMIT ?
  380. ''', (user_id, limit))
  381. rows = cursor.fetchall()
  382. return [dict(row) for row in reversed(rows)]
  383. except Exception as e:
  384. logger.error(f"Error fetching chat history: {e}")
  385. return []
  386. finally:
  387. if conn: conn.close()
  388. def delete_user_chat_history(user_id: int) -> bool:
  389. """Clear all chat messages for a user"""
  390. conn = None
  391. try:
  392. conn = get_db_connection()
  393. cursor = conn.cursor()
  394. cursor.execute("DELETE FROM chat_messages WHERE user_id = ?", (user_id,))
  395. conn.commit()
  396. return True
  397. except Exception as e:
  398. logger.error(f"Error deleting chat history: {e}")
  399. return False
  400. finally:
  401. if conn: conn.close()
  402. def get_user_profile(user_id: int) -> Optional[Dict[str, Any]]:
  403. """Fetch the user's profile containing macro targets. Inserts defaults if none exists."""
  404. conn = None
  405. try:
  406. conn = get_db_connection()
  407. cursor = conn.cursor()
  408. cursor.execute("SELECT * FROM user_profiles WHERE user_id = ?", (user_id,))
  409. row = cursor.fetchone()
  410. if not row:
  411. # Create a default profile row if one does not exist
  412. cursor.execute('''
  413. INSERT INTO user_profiles (user_id) VALUES (?)
  414. ''', (user_id,))
  415. conn.commit()
  416. cursor.execute("SELECT * FROM user_profiles WHERE user_id = ?", (user_id,))
  417. row = cursor.fetchone()
  418. return dict(row) if row else None
  419. except Exception as e:
  420. logger.error(f"Error fetching user profile: {e}")
  421. return None
  422. finally:
  423. if conn: conn.close()
  424. def get_food_by_id(food_id: int) -> Optional[Dict[str, Any]]:
  425. """Retrieve a single food item by its unique ID"""
  426. conn = None
  427. try:
  428. conn = get_db_connection()
  429. cursor = conn.cursor()
  430. cursor.execute("SELECT * FROM foods WHERE id = ?", (food_id,))
  431. row = cursor.fetchone()
  432. return dict(row) if row else None
  433. except Exception as e:
  434. logger.error(f"Error fetching food by ID {food_id}: {e}")
  435. return None
  436. finally:
  437. if conn: conn.close()
  438. def get_foods_by_ids(food_ids: List[int]) -> List[Dict[str, Any]]:
  439. """Retrieve multiple food items by their unique IDs in bulk"""
  440. if not food_ids:
  441. return []
  442. conn = None
  443. try:
  444. conn = get_db_connection()
  445. cursor = conn.cursor()
  446. # Create placeholders for the IN clause
  447. placeholders = ', '.join(['?'] * len(food_ids))
  448. query = f"SELECT * FROM foods WHERE id IN ({placeholders})"
  449. cursor.execute(query, food_ids)
  450. rows = cursor.fetchall()
  451. return [dict(row) for row in rows]
  452. except Exception as e:
  453. logger.error(f"Error fetching foods by IDs {food_ids}: {e}")
  454. return []
  455. finally:
  456. if conn: conn.close()