Commit 7dfba0af authored by Fares's avatar Fares

Migrate backend to Supabase PostgreSQL

parent 57b01db5
......@@ -8,9 +8,11 @@ import uuid
import bcrypt
from datetime import datetime
from fastapi import FastAPI, HTTPException, Request, Depends, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
import psycopg2.extras
from typing import Optional
from dotenv import load_dotenv
......@@ -31,6 +33,15 @@ except Exception as e:
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 ──────────────────────────────────────
_tokens: dict[str, dict] = {} # token -> {user_id, username, role, ...}
......@@ -89,7 +100,7 @@ def get_ui():
def login(req: LoginRequest):
from services.database import get_connection
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,))
user = cursor.fetchone()
......@@ -131,10 +142,10 @@ def register(req: RegisterRequest):
with get_connection() as conn:
cursor = conn.cursor()
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)
)
user_id = cursor.lastrowid
user_id = cursor.fetchone()[0]
except Exception as e:
if "Duplicate" in str(e):
raise HTTPException(status_code=409, detail="Username or email already exists")
......@@ -158,7 +169,7 @@ def get_me(user: dict = Depends(get_current_user)):
# Refresh from DB
from services.database import get_connection
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"],))
fresh = cursor.fetchone()
if fresh:
......@@ -369,7 +380,7 @@ def get_scenarios(page: int = 1, limit: int = 12, difficulty: str = "", category
where_sql = " AND ".join(where_clauses)
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)
total = cursor.fetchone()["total"]
......@@ -393,7 +404,7 @@ def get_scenarios(page: int = 1, limit: int = 12, difficulty: str = "", category
def get_scenario_detail(scenario_id: str):
from services.database import get_connection
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,))
scenario = cursor.fetchone()
if not scenario:
......@@ -430,7 +441,7 @@ def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)):
where_sql = " AND ".join(where_clauses)
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# Get random scenarios
cursor.execute(
......@@ -448,10 +459,10 @@ def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)):
# Create quiz
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"])
)
quiz_id = cursor.lastrowid
quiz_id = cursor.fetchone()['id']
# Link scenarios
for i, s in enumerate(scenarios):
......@@ -462,10 +473,10 @@ def start_quiz(req: StartQuizRequest, user: dict = Depends(get_current_user)):
# Create attempt
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))
)
attempt_id = cursor.lastrowid
attempt_id = cursor.fetchone()['id']
# Process scenarios for frontend
questions = []
......@@ -526,7 +537,7 @@ def submit_quiz(attempt_id: int, answers: list[AnswerRequest], user: dict = Depe
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# Verify attempt belongs to user
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
# Record history
cursor.execute(
"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
......@@ -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)):
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# Get overall stats
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)):
# Get accuracy
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"],)
)
accuracy = cursor.fetchone()
# Get category breakdown
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
JOIN scenarios s ON ush.scenario_id = s.id
WHERE ush.user_id = %s
......@@ -659,7 +670,7 @@ def get_user_stats(user: dict = Depends(get_current_user)):
def get_leaderboard():
from services.database import get_connection
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cursor.execute(
"""SELECT username, total_score, quizzes_taken, avatar,
CASE WHEN quizzes_taken > 0 THEN ROUND(total_score / quizzes_taken) ELSE 0 END as avg_score
......@@ -679,7 +690,7 @@ def get_leaderboard():
def get_filters():
from services.database import get_connection
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")
categories = [r["category"] for r in cursor.fetchall()]
cursor.execute("SELECT DISTINCT difficulty FROM scenarios WHERE is_active = 1 AND difficulty IS NOT NULL")
......
......@@ -16,12 +16,8 @@ class Settings(BaseSettings):
# ── SerpAPI ─────────────────────────────────────────────
SERPAPI_API_KEY: str = ""
# ── MySQL (Al-Arcade Remote DB) ───────────────────────
MYSQL_HOST: str = "scenariodb.caprover.al-arcade.com"
MYSQL_PORT: int = 3306
MYSQL_USER: str = "root"
MYSQL_PASSWORD: str = "Alarcade123#"
MYSQL_DATABASE: str = "mcq_app"
# ── Supabase (PostgreSQL) ─────────────────────────────────
SUPABASE_DB_URL: str = ""
# ── Defaults for Z-Score ──────────────────────────────
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 mysql.connector
from mysql.connector import pooling
import os
import psycopg2
from psycopg2 import pool
from contextlib import contextmanager
from config import get_settings
from models import Scenario, ScenarioGenerationResult
_pool: pool.SimpleConnectionPool | None = None
_pool: pooling.MySQLConnectionPool | None = None
def _get_pool() -> pooling.MySQLConnectionPool:
def _get_pool() -> pool.SimpleConnectionPool:
global _pool
if _pool is None:
s = get_settings()
_pool = pooling.MySQLConnectionPool(
pool_name="scenario_pool",
pool_size=5,
host=s.MYSQL_HOST,
port=s.MYSQL_PORT,
user=s.MYSQL_USER,
password=s.MYSQL_PASSWORD,
database=s.MYSQL_DATABASE,
# Fallback to SUPABASE_DB_URL from env or settings
db_url = os.environ.get("SUPABASE_DB_URL") or s.SUPABASE_DB_URL
if not db_url:
raise ValueError("SUPABASE_DB_URL is not set.")
_pool = pool.SimpleConnectionPool(
1, 10,
dsn=db_url
)
return _pool
@contextmanager
def get_connection():
conn = _get_pool().get_connection()
conn_pool = _get_pool()
conn = conn_pool.getconn()
try:
yield conn
conn.commit()
......@@ -40,36 +40,12 @@ def get_connection():
conn.rollback()
raise
finally:
conn.close()
conn_pool.putconn(conn)
def init_db():
"""Create the scenarios table if it doesn't exist."""
ddl = """
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)
"""No-op for Supabase as tables are managed via Supabase UI/Migrations."""
pass
def insert_scenario(s: Scenario):
......@@ -85,12 +61,12 @@ def insert_scenario(s: Scenario):
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
short_description = VALUES(short_description),
givens_table = VALUES(givens_table),
scenario_paragraph = VALUES(scenario_paragraph),
risk_level = VALUES(risk_level)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
short_description = EXCLUDED.short_description,
givens_table = EXCLUDED.givens_table,
scenario_paragraph = EXCLUDED.scenario_paragraph,
risk_level = EXCLUDED.risk_level
"""
# Pad extra options if Gemini returns less than 3
......@@ -124,12 +100,12 @@ def insert_scenario(s: Scenario):
)
with get_connection() as conn:
cursor = conn.cursor()
with conn.cursor() as cursor:
cursor.execute(sql, params)
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
for s in result.scenarios:
insert_scenario(s)
......@@ -147,11 +123,14 @@ def get_random_scenario() -> dict | None:
other_option3, other_option3_exp,
event_type, difficulty, category, risk_level
FROM scenarios
ORDER BY RAND()
ORDER BY RANDOM()
LIMIT 1
"""
import psycopg2.extras
with get_connection() as conn:
cursor = conn.cursor(dictionary=True)
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cursor:
cursor.execute(sql)
row = cursor.fetchone()
if row:
row = dict(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