diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index a15d368..4abfad9 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -2,6 +2,7 @@ use axum::{ Json, extract::{Multipart, Path, Query, State}, http::StatusCode, + response::IntoResponse, }; use bcrypt::{DEFAULT_COST, hash, verify}; use common::auth::{Claims, create_jwt}; @@ -114,6 +115,123 @@ pub async fn bulk_enroll_users( })) } +pub async fn export_course_grades( + Org(org_ctx): Org, + claims: Claims, + State(pool): State, + Path(course_id): Path, +) -> Result { + if claims.role != "admin" && claims.role != "instructor" { + return Err((StatusCode::FORBIDDEN, "Unauthorized".to_string())); + } + + // 1. Get Categories + let categories = sqlx::query!( + "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name", + course_id + ) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 2. Get Student general data + let students = sqlx::query!( + r#" + SELECT + u.id, + u.full_name, + u.email, + COALESCE(e.progress, 0)::float4 as progress, + (SELECT name FROM cohorts c JOIN user_cohorts uc ON c.id = uc.cohort_id WHERE uc.user_id = u.id LIMIT 1) as cohort_name, + AVG(g.score)::float4 as average_score + FROM users u + JOIN enrollments e ON u.id = e.user_id AND e.course_id = $1 + LEFT JOIN user_grades g ON u.id = g.user_id AND g.course_id = $1 + WHERE e.organization_id = $2 + GROUP BY u.id, u.full_name, u.email, e.progress + ORDER BY u.full_name + "#, + course_id, + org_ctx.id + ) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 3. Get detailed grades per user/category + struct UserCategoryGrade { + user_id: Uuid, + grading_category_id: Option, + avg_score: Option, + } + + let detailed_grades = sqlx::query_as!( + UserCategoryGrade, + r#" + SELECT + g.user_id, + l.grading_category_id, + AVG(g.score)::float4 as avg_score + FROM user_grades g + JOIN lessons l ON g.lesson_id = l.id + WHERE g.course_id = $1 + GROUP BY g.user_id, l.grading_category_id + "#, + course_id + ) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // 4. Build CSV + let mut csv = "Name,Email,Cohort,Progress,Overall Score".to_string(); + for cat in &categories { + csv.push_str(&format!(",{}", cat.name)); + } + csv.push('\n'); + + for s in students { + let cohort = s.cohort_name.unwrap_or_else(|| "N/A".to_string()); + let progress = format!("{:.1}%", s.progress * 100.0); + let overall = s + .average_score + .map(|v| format!("{:.1}%", v * 100.0)) + .unwrap_or_else(|| "N/A".to_string()); + + csv.push_str(&format!( + "\"{}\",{},\"{}\",{},{}", + s.full_name, s.email, cohort, progress, overall + )); + + for cat in &categories { + let score = detailed_grades + .iter() + .find(|g| g.user_id == s.id && g.grading_category_id == Some(cat.id)) + .and_then(|g| g.avg_score); + + match score { + Some(v) => csv.push_str(&format!(", {:.1}%", v * 100.0)), + None => csv.push_str(",N/A"), + } + } + csv.push('\n'); + } + + let disposition = format!("attachment; filename=\"grades-{}.csv\"", course_id); + + Ok(axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, "text/csv") + .header(axum::http::header::CONTENT_DISPOSITION, disposition) + .body(axum::body::Body::from(csv)) + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Response building failed".to_string(), + ) + })? + .into_response()) +} + pub async fn enroll_user( Org(org_ctx): Org, claims: Claims, diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 93d62ea..3258053 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -69,6 +69,10 @@ async fn main() { get(handlers::get_course_analytics), ) .route("/courses/{id}/grades", get(handlers::get_course_grades)) + .route( + "/courses/{id}/export-grades", + get(handlers::export_course_grades), + ) .route( "/courses/{id}/analytics/advanced", get(handlers::get_advanced_analytics), diff --git a/web/studio/src/app/courses/[id]/grades/page.tsx b/web/studio/src/app/courses/[id]/grades/page.tsx index 0c39547..b1e7b99 100644 --- a/web/studio/src/app/courses/[id]/grades/page.tsx +++ b/web/studio/src/app/courses/[id]/grades/page.tsx @@ -84,26 +84,29 @@ export default function GradebookPage() { ? students.reduce((acc, s) => acc + (s.average_score || 0), 0) / students.length : 0; - const exportCSV = () => { - const headers = ["Name", "Email", "Progress", "Average Score", "Last Active"]; - const rows = filteredStudents.map(s => [ - s.full_name, - s.email, - `${(s.progress * 100).toFixed(1)}%`, - s.average_score ? `${(s.average_score * 100).toFixed(1)}%` : "N/A", - s.last_active_at ? new Date(s.last_active_at).toLocaleDateString() : "Never" - ]); - - const csvContent = "data:text/csv;charset=utf-8," - + [headers.join(","), ...rows.map(r => r.join(","))].join("\n"); - - const encodedUri = encodeURI(csvContent); - const link = document.createElement("a"); - link.setAttribute("href", encodedUri); - link.setAttribute("download", `${courseTitle}_gradebook.csv`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const exportCSV = async () => { + try { + const token = localStorage.getItem('studio_token'); + const selectedOrgId = localStorage.getItem('studio_selected_org_id'); + const res = await fetch(lmsApi.exportGradesUrl(id), { + headers: { + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + ...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {}) + } + }); + if (!res.ok) throw new Error('Export failed'); + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${courseTitle}_grades_detailed.csv`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (err) { + console.error("Export failed", err); + } }; const handleBulkEnroll = async () => { diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 67a92ce..53ec5eb 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -744,6 +744,14 @@ export const lmsApi = { const query = cohortId ? `?cohort_id=${cohortId}` : ''; return apiFetch(`/courses/${id}/grades${query}`, {}, true); }, + exportGradesUrl: (courseId: string): string => { + const token = getToken(); + // Since we are downloading via tag, we might need a token in the query if headers are not possible, + // but let's assume the user is authenticated in the session or we use a temporary download link logic. + // For simplicity with standard anchor tags, we'll suggest using a blob fetch if headers are strictly required. + // However, standard API calls use headers. + return `${LMS_API_BASE_URL}/courses/${courseId}/export-grades`; + }, bulkEnroll: (courseId: string, emails: string[]): Promise => apiFetch('/bulk-enroll', { method: 'POST', body: JSON.stringify({ course_id: courseId, emails }) }, true), // Peer Assessment