tuklascope-api / app /api /v1 /endpoints /pathways.py
github-actions[bot]
Auto-deploy backend from GitHub Actions
e9aa59f
Raw
History Blame Contribute Delete
7.64 kB
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from supabase import Client
from app.core.security import get_user_db_client
from app.schemas.pathways import (
PathwayCatalogResponse,
PathwaySchema,
PathwayTaskSchema,
PathwayStatus,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/catalog", response_model=PathwayCatalogResponse)
async def get_pathway_catalog(
db_data: tuple[Client, str] = Depends(get_user_db_client),
):
db_client, user_id = db_data
try:
# 1. Fetch all pathways and their underlying tasks in a single query
catalog_res = (
db_client.table("pathways")
.select("*, pathway_tasks(id, task_description, order_index)")
.execute()
)
# 2. Fetch the user's specific progress state
user_state_res = (
db_client.table("user_pathways")
.select(
"pathway_id, status, badge_claimed_at, "
"user_pathway_tasks(task_id, is_completed)"
)
.eq("user_id", user_id)
.execute()
)
# 3. Map user state for O(1) memory lookups
user_enrollments = {
row["pathway_id"]: {
"status": row["status"],
"badge_claimed": row.get("badge_claimed_at") is not None,
"tasks": {
t["task_id"]: t["is_completed"]
for t in row.get("user_pathway_tasks", [])
},
}
for row in user_state_res.data
}
pathways_list = []
total_points_earned = 0
active_count = 0
total_progress_sum = 0
for p in catalog_res.data:
p_id = p["id"]
u_state = user_enrollments.get(
p_id,
{
"status": PathwayStatus.AVAILABLE.value,
"badge_claimed": False,
"tasks": {},
},
)
status_val = u_state["status"]
# Sort tasks safely by order_index
raw_tasks = sorted(
p.get("pathway_tasks", []), key=lambda x: x.get("order_index", 0)
)
mapped_tasks = []
completed_tasks = 0
for rt in raw_tasks:
is_done = u_state["tasks"].get(rt["id"], False)
if is_done:
completed_tasks += 1
mapped_tasks.append(
PathwayTaskSchema(
id=rt["id"],
description=rt["task_description"],
is_completed=is_done,
)
)
# Calculate dynamic progress
task_count = len(mapped_tasks)
progress_pct = (
int((completed_tasks / task_count * 100)) if task_count > 0 else 0
)
if status_val == PathwayStatus.ACTIVE.value:
active_count += 1
total_progress_sum += progress_pct
elif status_val == PathwayStatus.COMPLETED.value:
total_points_earned += p.get("total_points", 0)
pathways_list.append(
PathwaySchema(
id=p_id,
title=p["title"],
description=p["description"],
image_url=p["image_url"],
difficulty=p["difficulty"],
total_points=p.get("total_points", 0),
target_strand=p.get("target_strand", "GENERAL"),
status=status_val,
progress_percentage=progress_pct,
badge_claimed=u_state["badge_claimed"],
tasks=mapped_tasks,
)
)
average_progress = (
(total_progress_sum / active_count) if active_count > 0 else 0.0
)
return PathwayCatalogResponse(
active_pathways_count=active_count,
average_progress=round(average_progress, 1),
total_points_earned=total_points_earned,
pathways=pathways_list,
)
except Exception as e:
logger.error(f"Catalog fetch failed for user {user_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to load pathways catalog")
@router.post("/{pathway_id}/enroll")
async def enroll_in_pathway(
pathway_id: str, db_data: tuple[Client, str] = Depends(get_user_db_client)
):
db_client, user_id = db_data
try:
# Atomic Transaction execution via our Supabase RPC
# This replaces 4 brittle REST calls with 1 perfectly safe transaction.
res = db_client.rpc(
"enroll_user_in_pathway", {"p_user_id": user_id, "p_pathway_id": pathway_id}
).execute()
return res.data
except Exception as e:
error_message = str(e)
logger.error(
f"Failed to enroll in pathway {pathway_id}: {error_message}", exc_info=True
)
# Handle the specific exceptions raised from our PL/pgSQL function
if (
"already actively enrolled" in error_message
or "already completed" in error_message
or "attempted or abandoned" in error_message
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message.split("P0001: ")[
-1
], # Extract just the message if wrapped by Postgres
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Enrollment failed due to server error.",
)
@router.post("/{pathway_id}/claim-badge")
async def claim_pathway_badge(
pathway_id: str, db_data: tuple[Client, str] = Depends(get_user_db_client)
):
db_client, user_id = db_data
try:
enrollment_res = (
db_client.table("user_pathways")
.select("id, status, badge_claimed_at")
.eq("user_id", user_id)
.eq("pathway_id", pathway_id)
.maybe_single()
.execute()
)
enrollment = enrollment_res.data
if not enrollment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="You are not enrolled in this pathway.",
)
if enrollment.get("badge_claimed_at"):
return {
"status": "already_claimed",
"pathway_id": pathway_id,
}
if enrollment["status"] != PathwayStatus.COMPLETED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Complete every quest milestone before claiming your badge.",
)
claimed_at = datetime.now(timezone.utc).isoformat()
db_client.table("user_pathways").update(
{"badge_claimed_at": claimed_at}
).eq("id", enrollment["id"]).execute()
return {
"status": "claimed",
"pathway_id": pathway_id,
"badge_claimed_at": claimed_at,
}
except HTTPException:
raise
except Exception as e:
logger.error(
f"Failed to claim badge for pathway {pathway_id}, user {user_id}: {e}",
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to claim pathway badge.",
)