math-api / src /api /v1 /progress.py
False45's picture
Configure Firebase for production and prepare for deployment
4f811d2
Raw History Blame Contribute Delete
11.2 kB
"""
Progress Tracking API — /api/v1/progress endpoints.
Handles daily streak calculations and total problems solved per user.
Backed by Firestore.
"""
import logging
from datetime import datetime, timezone
from typing import Dict, List
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from backend.src.api.middleware.auth import verify_firebase_token
from backend.src.services.firebase_service import get_firestore_client
logger = logging.getLogger("math_assistant.api.progress")
router = APIRouter(prefix="/api/v1/progress", tags=["progress"])
class ProgressResponse(BaseModel):
streak: int
total_solved: int
last_solved_date: str
activity_map: Dict[str, int] = {}
weak_topics: List[str] = []
accuracy: int = 100
_MEMORY_STORE: Dict[str, dict] = {}
def get_memory_stats(uid: str) -> dict:
if uid not in _MEMORY_STORE:
_MEMORY_STORE[uid] = {
"streak": 0,
"total_solved": 0,
"last_solved_date": "",
"activity_map": {},
"weak_topics": [],
"accuracy": 100,
"correct_answers": 0,
"total_answers": 0
}
return _MEMORY_STORE[uid]
@router.get("", response_model=ProgressResponse)
async def get_progress(uid: str = Depends(verify_firebase_token)):
"""Get the user's current progress and streak."""
db = get_firestore_client()
if not db:
# Fallback if Firebase is disabled
stats = get_memory_stats(uid)
return ProgressResponse(**{k: v for k, v in stats.items() if k in ProgressResponse.__fields__})
try:
doc_ref = db.collection("users").document(uid).collection("profile").document("stats")
doc = doc_ref.get()
if doc.exists:
data = doc.to_dict()
return ProgressResponse(
streak=data.get("streak", 0),
total_solved=data.get("total_solved", 0),
last_solved_date=data.get("last_solved_date", ""),
activity_map=data.get("activity_map", {}),
weak_topics=data.get("weak_topics", []),
accuracy=data.get("accuracy", 100)
)
else:
return ProgressResponse(streak=0, total_solved=0, last_solved_date="")
except Exception as e:
logger.error(f"Error getting progress for {uid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/increment", response_model=ProgressResponse)
async def increment_progress(uid: str = Depends(verify_firebase_token)):
"""Increment the user's total solved count and update their streak."""
db = get_firestore_client()
today_str = datetime.now(timezone.utc).date().isoformat()
if not db:
stats = get_memory_stats(uid)
last_date = stats.get("last_solved_date", "")
streak = stats.get("streak", 0)
total = stats.get("total_solved", 0) + 1
activity_map = stats.get("activity_map", {})
activity_map[today_str] = activity_map.get(today_str, 0) + 1
if last_date != today_str:
try:
if last_date:
delta = (datetime.fromisoformat(today_str).date() - datetime.fromisoformat(last_date).date()).days
streak = (streak + 1) if delta == 1 else 1
else:
streak = 1
except ValueError:
streak = 1
stats.update({
"streak": streak,
"total_solved": total,
"last_solved_date": today_str,
"activity_map": activity_map
})
return ProgressResponse(**{k: v for k, v in stats.items() if k in ProgressResponse.__fields__})
try:
doc_ref = db.collection("users").document(uid).collection("profile").document("stats")
today_str = datetime.now(timezone.utc).date().isoformat()
# We need a transaction to safely update the streak
@from_firestore_transaction(db)
def update_in_transaction(transaction, ref, today):
snapshot = ref.get(transaction=transaction)
if not snapshot.exists:
data = {
"streak": 1,
"total_solved": 1,
"last_solved_date": today,
"activity_map": {today: 1},
"weak_topics": [],
"accuracy": 100,
"correct_answers": 1,
"total_answers": 1
}
transaction.set(ref, data)
return data
current_data = snapshot.to_dict()
last_date = current_data.get("last_solved_date", "")
streak = current_data.get("streak", 0)
total = current_data.get("total_solved", 0) + 1
activity_map = current_data.get("activity_map", {})
activity_map[today] = activity_map.get(today, 0) + 1
if last_date == today:
# Already solved a problem today, just increment total
pass
else:
# Need to check if it was yesterday
try:
last_date_obj = datetime.fromisoformat(last_date).date()
today_obj = datetime.fromisoformat(today).date()
delta = (today_obj - last_date_obj).days
if delta == 1:
streak += 1
else:
streak = 1
except ValueError:
streak = 1
data = {
"streak": streak,
"total_solved": total,
"last_solved_date": today,
"activity_map": activity_map,
"weak_topics": current_data.get("weak_topics", []),
"accuracy": current_data.get("accuracy", 100)
}
transaction.update(ref, data)
return data
new_stats = update_in_transaction(db.transaction(), doc_ref, today_str)
return ProgressResponse(**new_stats)
except Exception as e:
logger.error(f"Error incrementing progress for {uid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
class WeaknessRequest(BaseModel):
topic: str
@router.post("/weakness", response_model=ProgressResponse)
async def add_weakness(req: WeaknessRequest, uid: str = Depends(verify_firebase_token)):
"""Add a topic to the user's weak topics list."""
db = get_firestore_client()
if not db:
stats = get_memory_stats(uid)
topics = stats.get("weak_topics", [])
if req.topic not in topics:
topics.append(req.topic)
if len(topics) > 5:
topics = topics[-5:]
stats["weak_topics"] = topics
return ProgressResponse(**{k: v for k, v in stats.items() if k in ProgressResponse.__fields__})
doc_ref = db.collection("users").document(uid).collection("profile").document("stats")
@from_firestore_transaction(db)
def update_weakness(transaction, ref):
snapshot = ref.get(transaction=transaction)
if not snapshot.exists:
data = {"streak": 0, "total_solved": 0, "last_solved_date": "", "weak_topics": [req.topic], "accuracy": 100}
transaction.set(ref, data)
return data
data = snapshot.to_dict()
topics = data.get("weak_topics", [])
if req.topic not in topics:
topics.append(req.topic)
# keep only last 5
if len(topics) > 5:
topics = topics[-5:]
update_data = {"weak_topics": topics}
transaction.update(ref, update_data)
data.update(update_data)
return data
try:
new_stats = update_weakness(db.transaction(), doc_ref)
return ProgressResponse(
streak=new_stats.get("streak", 0),
total_solved=new_stats.get("total_solved", 0),
last_solved_date=new_stats.get("last_solved_date", ""),
activity_map=new_stats.get("activity_map", {}),
weak_topics=new_stats.get("weak_topics", []),
accuracy=new_stats.get("accuracy", 100)
)
except Exception as e:
logger.error(f"Error updating weakness: {e}")
raise HTTPException(status_code=500, detail=str(e))
class AccuracyRequest(BaseModel):
correct: bool
@router.post("/accuracy", response_model=ProgressResponse)
async def update_accuracy(req: AccuracyRequest, uid: str = Depends(verify_firebase_token)):
"""Update user's accuracy based on a correct/incorrect answer."""
db = get_firestore_client()
if not db:
stats = get_memory_stats(uid)
correct_ans = stats.get("correct_answers", 0) + (1 if req.correct else 0)
total_ans = stats.get("total_answers", 0) + 1
acc = int((correct_ans / total_ans) * 100)
stats.update({
"correct_answers": correct_ans,
"total_answers": total_ans,
"accuracy": acc
})
return ProgressResponse(**{k: v for k, v in stats.items() if k in ProgressResponse.__fields__})
doc_ref = db.collection("users").document(uid).collection("profile").document("stats")
@from_firestore_transaction(db)
def update_acc(transaction, ref):
snapshot = ref.get(transaction=transaction)
if not snapshot.exists:
data = {
"streak": 0, "total_solved": 0, "last_solved_date": "",
"correct_answers": 1 if req.correct else 0,
"total_answers": 1,
"accuracy": 100 if req.correct else 0
}
transaction.set(ref, data)
return data
data = snapshot.to_dict()
correct_ans = data.get("correct_answers", 0) + (1 if req.correct else 0)
total_ans = data.get("total_answers", 0) + 1
acc = int((correct_ans / total_ans) * 100)
update_data = {
"correct_answers": correct_ans,
"total_answers": total_ans,
"accuracy": acc
}
transaction.update(ref, update_data)
data.update(update_data)
return data
try:
new_stats = update_acc(db.transaction(), doc_ref)
return ProgressResponse(
streak=new_stats.get("streak", 0),
total_solved=new_stats.get("total_solved", 0),
last_solved_date=new_stats.get("last_solved_date", ""),
activity_map=new_stats.get("activity_map", {}),
weak_topics=new_stats.get("weak_topics", []),
accuracy=new_stats.get("accuracy", 100)
)
except Exception as e:
logger.error(f"Error updating accuracy: {e}")
raise HTTPException(status_code=500, detail=str(e))
def from_firestore_transaction(db):
"""Helper decorator for Firestore transactions."""
from firebase_admin import firestore
return firestore.transactional