| from fastapi import FastAPI, Depends, UploadFile, File, HTTPException, Header |
| from sqlalchemy.orm import Session |
| from backend.database import SessionLocal, engine, Source, Schedule, Mastery, init_db |
| from backend.rag_engine import ingest_document, query_knowledge_base |
| from backend.student_data import StudentProfileManager |
| import shutil |
| import os |
| from pydantic import BaseModel |
| from typing import List, Optional, Dict |
| import uuid |
| import logging |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| def _get_supabase(): |
| """Get Supabase client if configured. Returns None if not available.""" |
| try: |
| from backend.supabase_storage import SupabaseStorage |
| storage = SupabaseStorage() |
| if storage.is_available(): |
| return storage.client |
| except Exception: |
| pass |
| return None |
|
|
| |
| init_db() |
|
|
| app = FastAPI(title="FocusFlow Backend") |
|
|
| |
| @app.get("/health") |
| def health_check(): |
| """Health check endpoint""" |
| return {"status": "healthy"} |
|
|
| @app.get("/config") |
| async def get_config(): |
| from backend.config import IS_CLOUD, DEPLOYMENT_MODE |
| return { |
| "is_cloud": IS_CLOUD, |
| "deployment_mode": DEPLOYMENT_MODE, |
| "youtube_enabled": not IS_CLOUD |
| } |
|
|
|
|
| |
| def get_db(): |
| db = SessionLocal() |
| try: |
| yield db |
| finally: |
| db.close() |
|
|
| |
| def get_student_id(x_student_id: str = Header(default="anonymous")) -> str: |
| """Extract student ID from X-Student-Id header sent by the frontend.""" |
| return x_student_id if x_student_id else "anonymous" |
|
|
| |
| def get_profile_manager(x_student_id: str = Header(default="anonymous"), authorization: Optional[str] = Header(None)) -> StudentProfileManager: |
| """Get profile manager with session-specific student ID. |
| Uses X-Student-Id header (Firebase UID) for user isolation. |
| Falls back to Firebase token decoding, then to local_user.""" |
| from backend.config import is_firebase_configured |
|
|
| |
| if x_student_id and x_student_id != "anonymous": |
| student_id = x_student_id |
| elif is_firebase_configured(): |
| |
| if not authorization: |
| raise HTTPException(status_code=401, detail="Authorization header required") |
| token = authorization.replace("Bearer ", "", 1) |
| from backend.firebase_auth import verify_firebase_token |
| decoded = verify_firebase_token(token) |
| student_id = decoded["uid"] |
| else: |
| |
| student_id = "local_user" |
|
|
| return StudentProfileManager(student_id=student_id) |
|
|
| |
| @app.delete("/admin/clear_all_data") |
| async def clear_all_data(secret: str = "", db: Session = Depends(get_db)): |
| """One-time admin endpoint to wipe ALL existing data (all users). |
| Protected by ADMIN_SECRET environment variable.""" |
| expected_secret = os.environ.get("ADMIN_SECRET", "focusflow_clear") |
| if secret != expected_secret: |
| raise HTTPException(status_code=403, detail="Forbidden: invalid secret") |
|
|
| results = {} |
|
|
| |
| from backend.rag_engine import clear_all_chroma_data |
| results["chroma"] = "✅ cleared" if clear_all_chroma_data() else "❌ failed" |
|
|
| |
| try: |
| from backend.supabase_storage import SupabaseStorage |
| storage = SupabaseStorage() |
| if storage.is_available(): |
| results["supabase"] = "✅ cleared" if storage.clear_all_data() else "❌ failed" |
| else: |
| results["supabase"] = "⏭️ skipped (not configured)" |
| except Exception as e: |
| results["supabase"] = f"❌ error: {e}" |
|
|
| |
| try: |
| db.query(Source).delete() |
| db.query(Schedule).delete() |
| db.query(Mastery).delete() |
| db.commit() |
| results["sqlite"] = "✅ cleared" |
| except Exception as e: |
| db.rollback() |
| results["sqlite"] = f"❌ error: {e}" |
|
|
| |
| import shutil |
| from pathlib import Path |
| profile_dir = Path.home() / ".focusflow" |
| try: |
| if profile_dir.exists(): |
| shutil.rmtree(profile_dir) |
| results["local_profiles"] = "✅ cleared" |
| else: |
| results["local_profiles"] = "⏭️ skipped (not found)" |
| except Exception as e: |
| results["local_profiles"] = f"❌ error: {e}" |
|
|
| return {"status": "Data clear complete", "results": results} |
|
|
| |
| class ScheduleItem(BaseModel): |
| id: int |
| date: str |
| topic_name: str |
| is_completed: bool |
| is_locked: bool |
|
|
| class SourceItem(BaseModel): |
| id: int |
| filename: str |
| type: str |
| is_active: bool |
|
|
| class UnlockRequest(BaseModel): |
| topic_id: int |
| quiz_score: int |
|
|
| class UnlockResponse(BaseModel): |
| success: bool |
| message: str |
| next_topic_unlocked: bool |
|
|
| @app.post("/upload") |
| async def upload_file(file: UploadFile = File(...), db: Session = Depends(get_db), student_id: str = Depends(get_student_id)): |
| file_location = f"data/{file.filename}" |
| try: |
| with open(file_location, "wb+") as buffer: |
| shutil.copyfileobj(file.file, buffer) |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=f"Could not save file: {str(e)}") |
|
|
| |
| try: |
| ingest_document(file_location, student_id=student_id) |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=f"Ingestion failed: {str(e)}") |
| |
| |
| new_source = Source(student_id=student_id, filename=file.filename, type="local", file_path=file_location, is_active=True) |
| db.add(new_source) |
| db.commit() |
| db.refresh(new_source) |
| |
| |
| sb = _get_supabase() |
| if sb: |
| try: |
| sb.table("sources").insert({ |
| "student_id": student_id, |
| "name": file.filename, |
| "source_type": "pdf", |
| "file_path": file_location, |
| "is_active": True |
| }).execute() |
| except Exception as e: |
| logger.warning(f"Supabase source save failed: {e}") |
| |
| return {"message": "File uploaded and ingested successfully", "id": new_source.id} |
|
|
| class UrlRequest(BaseModel): |
| url: str |
|
|
| @app.post("/ingest_url") |
| def ingest_url_endpoint(request: UrlRequest, db: Session = Depends(get_db), student_id: str = Depends(get_student_id)): |
| try: |
| from backend.rag_engine import ingest_url |
| title = ingest_url(request.url, student_id=student_id) |
| |
| |
| new_source = Source(student_id=student_id, filename=title, type="url", file_path=request.url, is_active=True) |
| db.add(new_source) |
| db.commit() |
| db.refresh(new_source) |
| |
| |
| sb = _get_supabase() |
| if sb: |
| try: |
| sb.table("sources").insert({ |
| "student_id": student_id, |
| "name": title, |
| "source_type": "url", |
| "file_path": request.url, |
| "is_active": True |
| }).execute() |
| except Exception as e: |
| logger.warning(f"Supabase source save failed: {e}") |
| |
| return {"message": f"Successfully added: {title}", "id": new_source.id} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| class TextIngestionRequest(BaseModel): |
| text: str |
| source_name: str |
| source_type: str = "text" |
|
|
| @app.post("/ingest_text") |
| def ingest_text_endpoint(request: TextIngestionRequest, db: Session = Depends(get_db), student_id: str = Depends(get_student_id)): |
| """Ingest raw text content (e.g. browser-fetched YouTube transcripts).""" |
| try: |
| from backend.rag_engine import ingest_text |
| title = ingest_text(request.text, request.source_name, request.source_type, student_id=student_id) |
|
|
| |
| new_source = Source(student_id=student_id, filename=title, type=request.source_type, file_path=request.source_name, is_active=True) |
| db.add(new_source) |
| db.commit() |
| db.refresh(new_source) |
|
|
| |
| sb = _get_supabase() |
| if sb: |
| try: |
| sb.table("sources").insert({ |
| "student_id": student_id, |
| "name": title, |
| "source_type": request.source_type, |
| "file_path": request.source_name, |
| "is_active": True |
| }).execute() |
| except Exception as e: |
| logger.warning(f"Supabase source save failed: {e}") |
|
|
| return {"message": f"Successfully added: {title}", "id": new_source.id} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| class YouTubeIngestionRequest(BaseModel): |
| video_id: str |
|
|
| @app.post("/ingest_youtube") |
| def ingest_youtube(request: YouTubeIngestionRequest, db: Session = Depends(get_db), student_id: str = Depends(get_student_id)): |
| try: |
| from backend.rag_engine import get_youtube_transcript, ingest_text |
| |
| transcript_text = get_youtube_transcript(request.video_id) |
|
|
| |
| source_name = f"YouTube: {request.video_id}" |
| title = ingest_text( |
| text=transcript_text, |
| source_name=source_name, |
| source_type="youtube", |
| student_id=student_id |
| ) |
|
|
| |
| new_source = Source(student_id=student_id, filename=title, type="youtube", file_path=source_name, is_active=True) |
| db.add(new_source) |
| db.commit() |
| db.refresh(new_source) |
|
|
| |
| sb = _get_supabase() |
| if sb: |
| try: |
| sb.table("sources").insert({ |
| "student_id": student_id, |
| "name": title, |
| "source_type": "youtube", |
| "file_path": source_name, |
| "is_active": True |
| }).execute() |
| except Exception as e: |
| logger.warning(f"Supabase source save failed: {e}") |
|
|
| return {"status": "success", "message": f"Successfully added: {title}", "source": source_name, "id": new_source.id} |
|
|
| except ValueError as e: |
| raise HTTPException(status_code=400, detail=str(e)) |
| except Exception as e: |
| raise HTTPException( |
| status_code=500, |
| detail=f"Failed to process YouTube video: {str(e)}" |
| ) |
|
|
|
|
| @app.get("/sources", response_model=List[SourceItem]) |
| def get_sources(db: Session = Depends(get_db), student_id: str = Depends(get_student_id)): |
| |
| sb = _get_supabase() |
| if sb: |
| try: |
| result = sb.table("sources")\ |
| .select("*")\ |
| .eq("student_id", student_id)\ |
| .eq("is_active", True)\ |
| .execute() |
| if result.data: |
| |
| sources = [] |
| for row in result.data: |
| sources.append({ |
| "id": row.get("id", 0), |
| "filename": row.get("name", ""), |
| "type": row.get("source_type", "local"), |
| "file_path": row.get("file_path", ""), |
| "is_active": row.get("is_active", True) |
| }) |
| return sources |
| except Exception as e: |
| logger.warning(f"Supabase sources query failed, falling back to SQLite: {e}") |
| |
| |
| sources = db.query(Source).filter(Source.is_active == True, Source.student_id == student_id).all() |
| return sources |
|
|
| @app.delete("/sources/{source_id}") |
| def delete_source(source_id: int, db: Session = Depends(get_db), student_id: str = Depends(get_student_id)): |
| |
| source = db.query(Source).filter(Source.id == source_id, Source.student_id == student_id).first() |
| source_file_path = source.file_path if source else None |
| source_filename = source.filename if source else None |
| |
| |
| if source_file_path: |
| try: |
| from backend.rag_engine import delete_document |
| delete_document(source_file_path, student_id=student_id) |
| except Exception as e: |
| logger.warning(f"Failed to delete from vector store: {e}") |
|
|
| |
| if source: |
| source.is_active = False |
| db.commit() |
| |
| |
| sb = _get_supabase() |
| if sb: |
| try: |
| |
| sb.table("sources")\ |
| .update({"is_active": False})\ |
| .eq("student_id", student_id)\ |
| .eq("id", source_id)\ |
| .execute() |
| |
| if source_filename: |
| sb.table("sources")\ |
| .update({"is_active": False})\ |
| .eq("student_id", student_id)\ |
| .eq("name", source_filename)\ |
| .execute() |
| except Exception as e: |
| logger.warning(f"Supabase source delete failed: {e}") |
| |
| return {"success": True, "message": "Source deleted"} |
|
|
| @app.get("/schedule/{date}", response_model=List[ScheduleItem]) |
| def get_schedule(date: str, db: Session = Depends(get_db)): |
| |
| schedule_items = db.query(Schedule).filter(Schedule.date == date).all() |
| if not schedule_items: |
| |
| return [] |
| return schedule_items |
|
|
| @app.post("/unlock_topic", response_model=UnlockResponse) |
| def unlock_topic(request: UnlockRequest, db: Session = Depends(get_db)): |
| |
| |
| |
| |
| |
| current_topic = db.query(Schedule).filter(Schedule.id == request.topic_id).first() |
| if not current_topic: |
| raise HTTPException(status_code=404, detail="Topic not found") |
| |
| |
| |
| mastery = db.query(Mastery).filter(Mastery.topic_name == current_topic.topic_name).first() |
| if not mastery: |
| mastery = Mastery(topic_name=current_topic.topic_name, quiz_score=request.quiz_score) |
| db.add(mastery) |
| else: |
| mastery.quiz_score = request.quiz_score |
| |
| |
| if request.quiz_score > 60: |
| current_topic.is_completed = True |
| |
| |
| |
| next_topic = db.query(Schedule).filter(Schedule.id > current_topic.id).order_by(Schedule.id.asc()).first() |
| |
| next_unlocked = False |
| if next_topic: |
| next_topic.is_locked = False |
| next_unlocked = True |
| |
| db.commit() |
| return {"success": True, "message": "Quiz Passed! Next topic unlocked.", "next_topic_unlocked": next_unlocked} |
| else: |
| db.commit() |
| return {"success": False, "message": "Score too low. Try again!", "next_topic_unlocked": False} |
|
|
| class PlanRequest(BaseModel): |
| request_text: str |
|
|
| @app.post("/generate_plan") |
| def generate_plan_endpoint(request: PlanRequest, student_id: str = Depends(get_student_id)): |
| try: |
| from backend.rag_engine import generate_study_plan |
| plan = generate_study_plan(request.request_text, student_id=student_id) |
| return plan |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| class QueryRequest(BaseModel): |
| question: str |
| history: List[dict] = [] |
|
|
| @app.post("/query") |
| async def query_kb(request: QueryRequest, student_id: str = Depends(get_student_id)): |
| """ |
| RAG query endpoint. |
| """ |
| from backend.rag_engine import query_knowledge_base |
| response = query_knowledge_base(request.question, request.history, student_id=student_id) |
| return response |
|
|
| class LessonRequest(BaseModel): |
| topic: str |
|
|
| @app.post("/generate_lesson") |
| def generate_lesson_endpoint(request: LessonRequest, db: Session = Depends(get_db), student_id: str = Depends(get_student_id)): |
| try: |
| from backend.rag_engine import generate_lesson_content |
| content = generate_lesson_content(request.topic, student_id=student_id) |
| return {"content": content} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| class QuizRequest(BaseModel): |
| topic: str |
|
|
| @app.post("/generate_quiz") |
| def generate_quiz_endpoint(request: QuizRequest, student_id: str = Depends(get_student_id)): |
| try: |
| from backend.rag_engine import generate_quiz_data |
| quiz_data = generate_quiz_data(request.topic, student_id=student_id) |
| return {"quiz": quiz_data} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| |
|
|
| @app.get("/student/profile") |
| def get_student_profile(profile_manager: StudentProfileManager = Depends(get_profile_manager)): |
| """Load student profile""" |
| try: |
| profile = profile_manager.load_profile() |
| return profile |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| class SaveProgressRequest(BaseModel): |
| current_day: int |
| current_topic_id: Optional[int] |
| plan_id: Optional[str] |
|
|
| @app.post("/student/save_progress") |
| def save_progress(request: dict, profile_manager: StudentProfileManager = Depends(get_profile_manager)): |
| """Save current study state""" |
| try: |
| profile = profile_manager.load_profile() |
| |
| |
| if "current_study_day" in request: |
| profile["current_study_day"] = request["current_study_day"] |
| |
| |
| if "last_access_date" in request: |
| profile["last_access_date"] = request["last_access_date"] |
| |
| |
| if "current_day" in request: |
| profile["current_study_day"] = request["current_day"] |
| if "current_topic_id" in request: |
| profile.setdefault("current_topic_id", request["current_topic_id"]) |
| if "plan_id" in request: |
| if "study_plan" in profile: |
| profile["study_plan"]["plan_id"] = request["plan_id"] |
| |
| profile_manager.save_profile(profile) |
| return {"status": "success"} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| class SavePlanRequest(BaseModel): |
| topics: List[Dict] |
| num_days: int |
|
|
| @app.post("/student/save_plan") |
| def save_study_plan(request: SavePlanRequest, profile_manager: StudentProfileManager = Depends(get_profile_manager)): |
| """Save generated study plan""" |
| try: |
| plan_id = profile_manager.save_study_plan(request.topics, request.num_days) |
| return {"status": "saved", "plan_id": plan_id} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| class QuizCompleteRequest(BaseModel): |
| topic_id: int |
| topic_title: str |
| subject: str |
| score: int |
| total: int |
| time_taken: int = 0 |
|
|
| @app.post("/student/quiz_complete") |
| def record_quiz(request: QuizCompleteRequest, profile_manager: StudentProfileManager = Depends(get_profile_manager)): |
| """Record quiz completion""" |
| try: |
| profile_manager.update_quiz_score( |
| request.topic_id, |
| request.topic_title, |
| request.subject, |
| request.score, |
| request.total, |
| request.time_taken |
| ) |
| profile_manager.mark_topic_complete(request.topic_id) |
| return {"status": "recorded"} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| @app.get("/student/mastery") |
| def get_mastery_data(profile_manager: StudentProfileManager = Depends(get_profile_manager)): |
| """Get subject mastery data""" |
| try: |
| mastery = profile_manager.get_mastery_data() |
| return {"mastery": mastery} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| class IncompleteTaskRequest(BaseModel): |
| topic_id: int |
| from_day: int |
| reason: str = "not_completed" |
|
|
| @app.post("/student/incomplete_task") |
| def add_incomplete_task(request: IncompleteTaskRequest, profile_manager: StudentProfileManager = Depends(get_profile_manager)): |
| """Mark a task as incomplete""" |
| try: |
| profile_manager.add_incomplete_task( |
| request.topic_id, |
| request.from_day, |
| request.reason |
| ) |
| return {"status": "added"} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| @app.get("/student/incomplete_tasks/{current_day}") |
| def get_incomplete_tasks(current_day: int, profile_manager: StudentProfileManager = Depends(get_profile_manager)): |
| """Get incomplete tasks from previous days""" |
| try: |
| tasks = profile_manager.get_incomplete_tasks(current_day) |
| return {"incomplete_tasks": tasks} |
| except Exception as e: |
| raise HTTPException(status_code=500, detail=str(e)) |
|
|
| |
| class ProfileRequest(BaseModel): |
| uid: str |
| email: str |
| name: str |
| avatar_url: str = "" |
|
|
| @app.post("/auth/profile") |
| async def save_auth_profile(request: ProfileRequest): |
| """Save/update student profile in Supabase on login.""" |
| try: |
| from backend.supabase_storage import SupabaseStorage |
| storage = SupabaseStorage() |
| if storage.is_available(): |
| from datetime import datetime |
| storage.client.table("students").upsert({ |
| "uid": request.uid, |
| "email": request.email, |
| "name": request.name, |
| "avatar_url": request.avatar_url, |
| "last_login": datetime.now().isoformat() |
| }, on_conflict="uid").execute() |
| return {"status": "success"} |
| else: |
| return {"status": "skipped", "detail": "Supabase not configured"} |
| except Exception as e: |
| |
| return {"status": "error", "detail": str(e)} |