Commit 7dfba0af authored by Fares's avatar Fares

Migrate backend to Supabase PostgreSQL

parent 57b01db5
...@@ -8,9 +8,11 @@ import uuid ...@@ -8,9 +8,11 @@ import uuid
import bcrypt import bcrypt
from datetime import datetime from datetime import datetime
from fastapi import FastAPI, HTTPException, Request, Depends, UploadFile, File, Form from fastapi import FastAPI, HTTPException, Request, Depends, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import psycopg2.extras
from typing import Optional from typing import Optional
from dotenv import load_dotenv from dotenv import load_dotenv
...@@ -31,6 +33,15 @@ except Exception as e: ...@@ -31,6 +33,15 @@ except Exception as e:
app = FastAPI(title="FinSim", version="2.0.0") app = FastAPI(title="FinSim", version="2.0.0")
# ── CORS — allow Flutter web (and any dev origin) ────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── In-memory session store ────────────────────────────────────── # ── In-memory session store ──────────────────────────────────────
_tokens: dict[str, dict] = {} # token -> {user_id, username, role, ...} _tokens: dict[str, dict] = {} # token -> {user_id, username, role, ...}
...@@ -89,7 +100,7 @@ def get_ui(): ...@@ -89,7 +100,7 @@ def get_ui():
def login(req: LoginRequest): def login(req: LoginRequest):
from services.database import get_connection from services.database import get_connection
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cursor.execute("SELECT * FROM users WHERE email = %s", (req.email,)) cursor.execute("SELECT * FROM users WHERE email = %s", (req.email,))
user = cursor.fetchone() user = cursor.fetchone()
...@@ -131,10 +142,10 @@ def register(req: RegisterRequest): ...@@ -131,10 +142,10 @@ def register(req: RegisterRequest):
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"INSERT INTO users (username, email, password, role) VALUES (%s, %s, %s, 'user')", "INSERT INTO users (username, email, password, role) VALUES (%s, %s, %s, 'user') RETURNING id",
(req.username, req.email, hashed) (req.username, req.email, hashed)
) )
user_id = cursor.lastrowid user_id = cursor.fetchone()[0]
except Exception as e: except Exception as e:
if "Duplicate" in str(e): if "Duplicate" in str(e):
raise HTTPException(status_code=409, detail="Username or email already exists") raise HTTPException(status_code=409, detail="Username or email already exists")
...@@ -158,7 +169,7 @@ def get_me(user: dict = Depends(get_current_user)): ...@@ -158,7 +169,7 @@ def get_me(user: dict = Depends(get_current_user)):
# Refresh from DB # Refresh from DB
from services.database import get_connection from services.database import get_connection
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cursor.execute("SELECT total_score, quizzes_taken FROM users WHERE id = %s", (user["user_id"],)) cursor.execute("SELECT total_score, quizzes_taken FROM users WHERE id = %s", (user["user_id"],))
fresh = cursor.fetchone() fresh = cursor.fetchone()
if fresh: if fresh:
...@@ -369,7 +380,7 @@ def get_scenarios(page: int = 1, limit: int = 12, difficulty: str = "", category ...@@ -369,7 +380,7 @@ def get_scenarios(page: int = 1, limit: int = 12, difficulty: str = "", category
where_sql = " AND ".join(where_clauses) where_sql = " AND ".join(where_clauses)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cursor.execute(f"SELECT COUNT(*) as total FROM scenarios WHERE {where_sql}", params) cursor.execute(f"SELECT COUNT(*) as total FROM scenarios WHERE {where_sql}", params)
total = cursor.fetchone()["total"] total = cursor.fetchone()["total"]
...@@ -393,7 +404,7 @@ def get_scenarios(page: int = 1, limit: int = 12, difficulty: str = "", category ...@@ -393,7 +404,7 @@ def get_scenarios(page: int = 1, limit: int = 12, difficulty: str = "", category
def get_scenario_detail(scenario_id: str): def get_scenario_detail(scenario_id: str):
from services.database import get_connection from services.database import get_connection
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cursor.execute("SELECT * FROM scenarios WHERE id = %s", (scenario_id,)) cursor.execute("SELECT * FROM scenarios WHERE id = %s", (scenario_id,))
scenario = cursor.fetchone() scenario = cursor.fetchone()
if not scenario: if not scenario:
...@@ -430,7 +441,7 @@ def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)): ...@@ -430,7 +441,7 @@ def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)):
where_sql = " AND ".join(where_clauses) where_sql = " AND ".join(where_clauses)
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# Get random scenarios # Get random scenarios
cursor.execute( cursor.execute(
...@@ -448,10 +459,10 @@ def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)): ...@@ -448,10 +459,10 @@ def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)):
# Create quiz # Create quiz
cursor.execute( cursor.execute(
"INSERT INTO quizzes (title, description, time_limit, passing_score, created_by) VALUES (%s, %s, %s, %s, %s)", "INSERT INTO quizzes (title, description, time_limit, passing_score, created_by) VALUES (%s, %s, %s, %s, %s) RETURNING id",
(f"Quick Quiz - {datetime.now().strftime('%b %d, %H:%M')}", f"{len(scenarios)} questions", 0, 60, user["user_id"]) (f"Quick Quiz - {datetime.now().strftime('%b %d, %H:%M')}", f"{len(scenarios)} questions", 0, 60, user["user_id"])
) )
quiz_id = cursor.lastrowid quiz_id = cursor.fetchone()['id']
# Link scenarios # Link scenarios
for i, s in enumerate(scenarios): for i, s in enumerate(scenarios):
...@@ -462,10 +473,10 @@ def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)): ...@@ -462,10 +473,10 @@ def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)):
# Create attempt # Create attempt
cursor.execute( cursor.execute(
"INSERT INTO quiz_attempts (user_id, quiz_id, total_questions, status) VALUES (%s, %s, %s, 'in_progress')", "INSERT INTO quiz_attempts (user_id, quiz_id, total_questions, status) VALUES (%s, %s, %s, 'in_progress') RETURNING id",
(user["user_id"], quiz_id, len(scenarios)) (user["user_id"], quiz_id, len(scenarios))
) )
attempt_id = cursor.lastrowid attempt_id = cursor.fetchone()['id']
# Process scenarios for frontend # Process scenarios for frontend
questions = [] questions = []
...@@ -526,7 +537,7 @@ def submit_quiz(attempt_id: int, answers: list[AnswerRequest], user: dict = Depe ...@@ -526,7 +537,7 @@ def submit_quiz(attempt_id: int, answers: list[AnswerRequest], user: dict = Depe
from services.database import get_connection from services.database import get_connection
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# Verify attempt belongs to user # Verify attempt belongs to user
cursor.execute("SELECT * FROM quiz_attempts WHERE id = %s AND user_id = %s", (attempt_id, user["user_id"])) cursor.execute("SELECT * FROM quiz_attempts WHERE id = %s AND user_id = %s", (attempt_id, user["user_id"]))
...@@ -551,7 +562,7 @@ def submit_quiz(attempt_id: int, answers: list[AnswerRequest], user: dict = Depe ...@@ -551,7 +562,7 @@ def submit_quiz(attempt_id: int, answers: list[AnswerRequest], user: dict = Depe
# Record history # Record history
cursor.execute( cursor.execute(
"INSERT INTO user_scenario_history (user_id, scenario_id, selected_answer, is_correct, attempt_id) VALUES (%s, %s, %s, %s, %s)", "INSERT INTO user_scenario_history (user_id, scenario_id, selected_answer, is_correct, attempt_id) VALUES (%s, %s, %s, %s, %s)",
(user["user_id"], ans.scenario_id, ans.selected_answer, 1 if is_correct else 0, attempt_id) (user["user_id"], ans.scenario_id, ans.selected_answer, is_correct, attempt_id)
) )
# Build explanation # Build explanation
...@@ -604,7 +615,7 @@ def submit_quiz(attempt_id: int, answers: list[AnswerRequest], user: dict = Depe ...@@ -604,7 +615,7 @@ def submit_quiz(attempt_id: int, answers: list[AnswerRequest], user: dict = Depe
def get_user_stats(user: dict = Depends(get_current_user)): def get_user_stats(user: dict = Depends(get_current_user)):
from services.database import get_connection from services.database import get_connection
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# Get overall stats # Get overall stats
cursor.execute("SELECT total_score, quizzes_taken FROM users WHERE id = %s", (user["user_id"],)) cursor.execute("SELECT total_score, quizzes_taken FROM users WHERE id = %s", (user["user_id"],))
...@@ -627,14 +638,14 @@ def get_user_stats(user: dict = Depends(get_current_user)): ...@@ -627,14 +638,14 @@ def get_user_stats(user: dict = Depends(get_current_user)):
# Get accuracy # Get accuracy
cursor.execute( cursor.execute(
"SELECT COUNT(*) as total, SUM(is_correct) as correct FROM user_scenario_history WHERE user_id = %s", "SELECT COUNT(*) as total, SUM(is_correct::int) as correct FROM user_scenario_history WHERE user_id = %s",
(user["user_id"],) (user["user_id"],)
) )
accuracy = cursor.fetchone() accuracy = cursor.fetchone()
# Get category breakdown # Get category breakdown
cursor.execute( cursor.execute(
"""SELECT s.category, COUNT(*) as attempts, SUM(ush.is_correct) as correct """SELECT s.category, COUNT(*) as attempts, SUM(ush.is_correct::int) as correct
FROM user_scenario_history ush FROM user_scenario_history ush
JOIN scenarios s ON ush.scenario_id = s.id JOIN scenarios s ON ush.scenario_id = s.id
WHERE ush.user_id = %s WHERE ush.user_id = %s
...@@ -659,7 +670,7 @@ def get_user_stats(user: dict = Depends(get_current_user)): ...@@ -659,7 +670,7 @@ def get_user_stats(user: dict = Depends(get_current_user)):
def get_leaderboard(): def get_leaderboard():
from services.database import get_connection from services.database import get_connection
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cursor.execute( cursor.execute(
"""SELECT username, total_score, quizzes_taken, avatar, """SELECT username, total_score, quizzes_taken, avatar,
CASE WHEN quizzes_taken > 0 THEN ROUND(total_score / quizzes_taken) ELSE 0 END as avg_score CASE WHEN quizzes_taken > 0 THEN ROUND(total_score / quizzes_taken) ELSE 0 END as avg_score
...@@ -679,7 +690,7 @@ def get_leaderboard(): ...@@ -679,7 +690,7 @@ def get_leaderboard():
def get_filters(): def get_filters():
from services.database import get_connection from services.database import get_connection
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cursor.execute("SELECT DISTINCT category FROM scenarios WHERE is_active = 1 AND category IS NOT NULL") cursor.execute("SELECT DISTINCT category FROM scenarios WHERE is_active = 1 AND category IS NOT NULL")
categories = [r["category"] for r in cursor.fetchall()] categories = [r["category"] for r in cursor.fetchall()]
cursor.execute("SELECT DISTINCT difficulty FROM scenarios WHERE is_active = 1 AND difficulty IS NOT NULL") cursor.execute("SELECT DISTINCT difficulty FROM scenarios WHERE is_active = 1 AND difficulty IS NOT NULL")
......
...@@ -16,12 +16,8 @@ class Settings(BaseSettings): ...@@ -16,12 +16,8 @@ class Settings(BaseSettings):
# ── SerpAPI ───────────────────────────────────────────── # ── SerpAPI ─────────────────────────────────────────────
SERPAPI_API_KEY: str = "" SERPAPI_API_KEY: str = ""
# ── MySQL (Al-Arcade Remote DB) ─────────────────────── # ── Supabase (PostgreSQL) ─────────────────────────────────
MYSQL_HOST: str = "scenariodb.caprover.al-arcade.com" SUPABASE_DB_URL: str = ""
MYSQL_PORT: int = 3306
MYSQL_USER: str = "root"
MYSQL_PASSWORD: str = "Alarcade123#"
MYSQL_DATABASE: str = "mcq_app"
# ── Defaults for Z-Score ────────────────────────────── # ── Defaults for Z-Score ──────────────────────────────
DEFAULT_ZSCORE_WINDOW: int = 100 DEFAULT_ZSCORE_WINDOW: int = 100
......
[build]
builder = "DOCKERFILE"
dockerfilePath = "Dockerfile"
[deploy]
healthcheckPath = "/"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3
bcrypt>=5.0.0
beautifulsoup4>=4.14.3
chromadb>=1.5.5
fastapi>=0.135.1
langchain>=0.3.0
langchain-chroma>=1.1.0
langchain-community>=0.4.1
langchain-core>=0.3.0
langchain-groq>=0.2.0
langchain-huggingface>=1.2.1
langgraph>=1.1.1
psycopg2-binary>=2.9.9
numpy>=1.24
polars>=1.38.1
pyarrow>=23.0.1
pydantic>=2.12.5
pydantic-settings>=2.13.1
pypdf>=6.9.2
python-dotenv>=1.2.2
python-multipart>=0.0.22
sentence-transformers>=5.3.0
uvicorn>=0.41.0
yfinance>=1.2.0
""" """
Database layer — MySQL operations connecting directly to CapRover instance. Database layer — PostgreSQL operations connecting directly to Supabase.
""" """
import json import json
import mysql.connector import os
from mysql.connector import pooling import psycopg2
from psycopg2 import pool
from contextlib import contextmanager from contextlib import contextmanager
from config import get_settings from config import get_settings
from models import Scenario, ScenarioGenerationResult from models import Scenario, ScenarioGenerationResult
_pool: pool.SimpleConnectionPool | None = None
_pool: pooling.MySQLConnectionPool | None = None def _get_pool() -> pool.SimpleConnectionPool:
def _get_pool() -> pooling.MySQLConnectionPool:
global _pool global _pool
if _pool is None: if _pool is None:
s = get_settings() s = get_settings()
_pool = pooling.MySQLConnectionPool( # Fallback to SUPABASE_DB_URL from env or settings
pool_name="scenario_pool", db_url = os.environ.get("SUPABASE_DB_URL") or s.SUPABASE_DB_URL
pool_size=5, if not db_url:
host=s.MYSQL_HOST, raise ValueError("SUPABASE_DB_URL is not set.")
port=s.MYSQL_PORT,
user=s.MYSQL_USER, _pool = pool.SimpleConnectionPool(
password=s.MYSQL_PASSWORD, 1, 10,
database=s.MYSQL_DATABASE, dsn=db_url
) )
return _pool return _pool
@contextmanager @contextmanager
def get_connection(): def get_connection():
conn = _get_pool().get_connection() conn_pool = _get_pool()
conn = conn_pool.getconn()
try: try:
yield conn yield conn
conn.commit() conn.commit()
...@@ -40,36 +40,12 @@ def get_connection(): ...@@ -40,36 +40,12 @@ def get_connection():
conn.rollback() conn.rollback()
raise raise
finally: finally:
conn.close() conn_pool.putconn(conn)
def init_db(): def init_db():
"""Create the scenarios table if it doesn't exist.""" """No-op for Supabase as tables are managed via Supabase UI/Migrations."""
ddl = """ pass
CREATE TABLE IF NOT EXISTS scenarios (
id VARCHAR(20) PRIMARY KEY,
title TEXT NOT NULL,
short_description TEXT,
givens_table JSON,
scenario_paragraph TEXT,
best_answer TEXT,
best_answer_rationale TEXT,
other_option1 TEXT,
other_option1_exp TEXT,
other_option2 TEXT,
other_option2_exp TEXT,
other_option3 TEXT,
other_option3_exp TEXT,
event_type VARCHAR(20) DEFAULT 'normal',
difficulty VARCHAR(20) DEFAULT 'medium',
category VARCHAR(100) DEFAULT 'General',
risk_level VARCHAR(20) DEFAULT 'Medium',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"""
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute(ddl)
def insert_scenario(s: Scenario): def insert_scenario(s: Scenario):
...@@ -85,12 +61,12 @@ def insert_scenario(s: Scenario): ...@@ -85,12 +61,12 @@ def insert_scenario(s: Scenario):
) VALUES ( ) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
) )
ON DUPLICATE KEY UPDATE ON CONFLICT (id) DO UPDATE SET
title = VALUES(title), title = EXCLUDED.title,
short_description = VALUES(short_description), short_description = EXCLUDED.short_description,
givens_table = VALUES(givens_table), givens_table = EXCLUDED.givens_table,
scenario_paragraph = VALUES(scenario_paragraph), scenario_paragraph = EXCLUDED.scenario_paragraph,
risk_level = VALUES(risk_level) risk_level = EXCLUDED.risk_level
""" """
# Pad extra options if Gemini returns less than 3 # Pad extra options if Gemini returns less than 3
...@@ -124,12 +100,12 @@ def insert_scenario(s: Scenario): ...@@ -124,12 +100,12 @@ def insert_scenario(s: Scenario):
) )
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() with conn.cursor() as cursor:
cursor.execute(sql, params) cursor.execute(sql, params)
def insert_all_scenarios(result: ScenarioGenerationResult) -> int: def insert_all_scenarios(result: ScenarioGenerationResult) -> int:
"""Bulk insert all scenarios to CapRover database. Returns count inserted.""" """Bulk insert all scenarios to Supabase database. Returns count inserted."""
count = 0 count = 0
for s in result.scenarios: for s in result.scenarios:
insert_scenario(s) insert_scenario(s)
...@@ -147,11 +123,14 @@ def get_random_scenario() -> dict | None: ...@@ -147,11 +123,14 @@ def get_random_scenario() -> dict | None:
other_option3, other_option3_exp, other_option3, other_option3_exp,
event_type, difficulty, category, risk_level event_type, difficulty, category, risk_level
FROM scenarios FROM scenarios
ORDER BY RAND() ORDER BY RANDOM()
LIMIT 1 LIMIT 1
""" """
import psycopg2.extras
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor(dictionary=True) with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cursor:
cursor.execute(sql) cursor.execute(sql)
row = cursor.fetchone() row = cursor.fetchone()
if row:
row = dict(row)
return row return row
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment