From cb13b14ee040bc662f0bf4494ab171b4973fcf6a Mon Sep 17 00:00:00 2001 From: Nurfog Date: Mon, 16 Feb 2026 04:44:31 -0300 Subject: [PATCH] feat: Implement course gradebook with cohort filtering, CSV export, and extend analytics with cohort selection. --- services/lms-service/src/handlers.rs | 72 ++++- services/lms-service/src/main.rs | 1 + shared/common/src/models.rs | 10 + .../src/app/courses/[id]/analytics/page.tsx | 20 +- .../src/app/courses/[id]/grades/page.tsx | 261 ++++++++++++++++++ .../src/components/CourseEditorLayout.tsx | 7 +- web/studio/src/lib/api.ts | 23 +- 7 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 web/studio/src/app/courses/[id]/grades/page.tsx diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 0331456..37efde1 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -884,6 +884,46 @@ pub async fn get_leaderboard( Ok(Json(response)) } +pub async fn get_course_grades( + Org(org_ctx): Org, + State(pool): State, + Path(course_id): Path, + Query(filter): Query, +) -> Result>, (StatusCode, String)> { + let rows = sqlx::query_as::<_, common::models::StudentGradeReport>( + r#" + SELECT + u.id as user_id, + u.full_name, + u.email, + COALESCE(e.progress, 0)::float4 as progress, + AVG(g.score)::float4 as average_score, + e.updated_at as last_active_at + FROM users u + JOIN enrollments e ON u.id = e.user_id + AND e.course_id = $1 + AND e.organization_id = $2 + LEFT JOIN user_grades g ON u.id = g.user_id AND g.course_id = $1 + WHERE ($3::uuid IS NULL OR EXISTS ( + SELECT 1 FROM user_cohorts uc WHERE uc.user_id = u.id AND uc.cohort_id = $3 + )) + GROUP BY u.id, u.full_name, u.email, e.progress, e.updated_at + ORDER BY u.full_name + "#, + ) + .bind(course_id) + .bind(org_ctx.id) + .bind(filter.cohort_id) + .fetch_all(&pool) + .await + .map_err(|e| { + tracing::error!("Failed to fetch course grades: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + + Ok(Json(rows)) +} + pub async fn get_user_course_grades( Org(_org_ctx): Org, State(pool): State, @@ -900,27 +940,51 @@ pub async fn get_user_course_grades( Ok(Json(grades)) } +#[derive(Deserialize)] +pub struct AnalyticsFilter { + pub cohort_id: Option, +} + pub async fn get_course_analytics( Org(org_ctx): Org, State(pool): State, Path(course_id): Path, + Query(filter): Query, ) -> Result, (StatusCode, String)> { // 1. Total Enrollments let total_enrollments: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM enrollments WHERE course_id = $1 AND organization_id = $2", + r#" + SELECT COUNT(*) + FROM enrollments e + WHERE e.course_id = $1 + AND e.organization_id = $2 + AND ($3::uuid IS NULL OR EXISTS ( + SELECT 1 FROM user_cohorts uc WHERE uc.user_id = e.user_id AND uc.cohort_id = $3 + )) + "#, ) .bind(course_id) .bind(org_ctx.id) + .bind(filter.cohort_id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 2. Average Course Score (Overall) let average_score: Option = sqlx::query_scalar( - "SELECT AVG(score)::float4 FROM user_grades WHERE course_id = $1 AND organization_id = $2", + r#" + SELECT AVG(score)::float4 + FROM user_grades g + WHERE g.course_id = $1 + AND g.organization_id = $2 + AND ($3::uuid IS NULL OR EXISTS ( + SELECT 1 FROM user_cohorts uc WHERE uc.user_id = g.user_id AND uc.cohort_id = $3 + )) + "#, ) .bind(course_id) .bind(org_ctx.id) + .bind(filter.cohort_id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -936,6 +1000,9 @@ pub async fn get_course_analytics( COUNT(g.id) as submission_count FROM lessons l LEFT JOIN user_grades g ON l.id = g.lesson_id + AND ($3::uuid IS NULL OR EXISTS ( + SELECT 1 FROM user_cohorts uc WHERE uc.user_id = g.user_id AND uc.cohort_id = $3 + )) WHERE l.module_id IN (SELECT id FROM modules WHERE course_id = $1) AND l.organization_id = $2 GROUP BY l.id, l.title, l.position ORDER BY l.position @@ -943,6 +1010,7 @@ pub async fn get_course_analytics( ) .bind(course_id) .bind(org_ctx.id) + .bind(filter.cohort_id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 1ff13a6..7aeb437 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -66,6 +66,7 @@ async fn main() { "/courses/{id}/analytics", get(handlers::get_course_analytics), ) + .route("/courses/{id}/grades", get(handlers::get_course_grades)) .route( "/courses/{id}/analytics/advanced", get(handlers::get_advanced_analytics), diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 9639c8a..ad77dc4 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -481,6 +481,16 @@ pub struct AddMemberPayload { pub user_id: Uuid, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)] +pub struct StudentGradeReport { + pub user_id: Uuid, + pub full_name: String, + pub email: String, + pub progress: f32, + pub average_score: Option, + pub last_active_at: Option>, +} + #[cfg(test)] mod tests { use super::*; diff --git a/web/studio/src/app/courses/[id]/analytics/page.tsx b/web/studio/src/app/courses/[id]/analytics/page.tsx index fc556e3..539b5b8 100644 --- a/web/studio/src/app/courses/[id]/analytics/page.tsx +++ b/web/studio/src/app/courses/[id]/analytics/page.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { useParams, useRouter } from "next/navigation"; -import { cmsApi, Course, CourseAnalytics } from "@/lib/api"; +import { cmsApi, Cohort, Course, CourseAnalytics, lmsApi } from "@/lib/api"; import { useAuth } from "@/context/AuthContext"; import { BarChart3, @@ -24,6 +24,8 @@ export default function AnalyticsPage() { const [analytics, setAnalytics] = useState(null); const [loading, setLoading] = useState(true); const [authError, setAuthError] = useState(null); + const [cohorts, setCohorts] = useState([]); + const [selectedCohortId, setSelectedCohortId] = useState(""); useEffect(() => { const fetchData = async () => { @@ -39,9 +41,13 @@ export default function AnalyticsPage() { } try { + // Fetch cohorts once + const cohortsData = await lmsApi.getCohorts(); + setCohorts(cohortsData); + const [courseData, analyticsData] = await Promise.all([ cmsApi.getCourseWithFullOutline(id), - cmsApi.getCourseAnalytics(id) + cmsApi.getCourseAnalytics(id, selectedCohortId || undefined) ]); setCourse(courseData); setAnalytics(analyticsData); @@ -53,7 +59,7 @@ export default function AnalyticsPage() { } }; fetchData(); - }, [id, user, router]); + }, [id, user, router, selectedCohortId]); if (loading) return (
@@ -102,6 +108,14 @@ export default function AnalyticsPage() {
+ +
+

+ Gradebook +

+

Student, progress, and performance tracking

+
+
+
+ +
+ + + +
+ {/* Controls & Stats */} +
+
+
+ +
+ ▼ +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full bg-black/20 border border-white/10 rounded-xl pl-10 pr-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 text-white placeholder-gray-600" + /> +
+
+ +
+
+

Students

+

{filteredStudents.length}

+
+
+

Avg Score

+

+ {(averageScore * 100).toFixed(0)}% +

+
+
+
+ + {/* Student List */} +
+ + + + + + + + + + + + {filteredStudents.length === 0 ? ( + + + + ) : filteredStudents.map((s) => ( + + + + + + + + ))} + +
StudentProgressAvg. ScoreLast ActiveStatus
No students found.
+
+
+ {s.full_name.charAt(0)} +
+
+
{s.full_name}
+
+ {s.email} +
+
+
+
+
+
+ {(s.progress * 100).toFixed(0)}% +
+
+
+
+
+
+ {s.average_score !== null ? ( + = 0.8 ? 'text-green-400' : (s.average_score || 0) >= 0.6 ? 'text-yellow-400' : 'text-red-400'}`}> + {((s.average_score || 0) * 100).toFixed(0)}% + + ) : ( + No grades + )} + +
+ + {s.last_active_at ? new Date(s.last_active_at).toLocaleDateString() : 'Never'} +
+
+ {s.progress >= 1 ? ( + + Completed + + ) : s.last_active_at && (Date.now() - new Date(s.last_active_at).getTime() > 7 * 24 * 60 * 60 * 1000) ? ( + + Inactive + + ) : ( + + Active + + )} +
+
+
+
+ + + ); +} diff --git a/web/studio/src/components/CourseEditorLayout.tsx b/web/studio/src/components/CourseEditorLayout.tsx index 5db54b6..a0a4e02 100644 --- a/web/studio/src/components/CourseEditorLayout.tsx +++ b/web/studio/src/components/CourseEditorLayout.tsx @@ -3,11 +3,11 @@ import React from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder } from "lucide-react"; +import { Layout, CheckCircle2, Calendar, BarChart2, Settings, Folder, GraduationCap } from "lucide-react"; interface CourseEditorLayoutProps { children: React.ReactNode; - activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings" | "files"; + activeTab: "outline" | "grading" | "calendar" | "analytics" | "settings" | "files" | "grades"; } export default function CourseEditorLayout({ children, activeTab }: CourseEditorLayoutProps) { @@ -15,7 +15,8 @@ export default function CourseEditorLayout({ children, activeTab }: CourseEditor const tabs = [ { key: "outline", label: "Outline", icon: Layout, href: `/courses/${id}` }, - { key: "grading", label: "Grading", icon: CheckCircle2, href: `/courses/${id}/grading` }, + { key: "grading", label: "Grading Policy", icon: CheckCircle2, href: `/courses/${id}/grading` }, + { key: "grades", label: "Gradebook", icon: GraduationCap, href: `/courses/${id}/grades` }, { key: "calendar", label: "Calendar", icon: Calendar, href: `/courses/${id}/calendar` }, { key: "analytics", label: "Analytics", icon: BarChart2, href: `/courses/${id}/analytics` }, { key: "files", label: "Files & Uploads", icon: Folder, href: `/courses/${id}/files` }, diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index a307d52..a522a9d 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -291,6 +291,15 @@ export interface AddMemberPayload { user_id: string; } +export interface StudentGradeReport { + user_id: string; + full_name: string; + email: string; + progress: number; + average_score: number | null; + last_active_at: string | null; +} + const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('studio_token') : null; const getSelectedOrgId = () => typeof window !== 'undefined' ? localStorage.getItem('studio_selected_org_id') : null; @@ -362,8 +371,14 @@ export const cmsApi = { // Admin & Analytics getAuditLogs: (): Promise => apiFetch('/audit-logs'), - getCourseAnalytics: (id: string): Promise => apiFetch(`/courses/${id}/analytics`), - getAdvancedAnalytics: (id: string): Promise => apiFetch(`/courses/${id}/analytics/advanced`), + getCourseAnalytics: (id: string, cohortId?: string): Promise => { + const query = cohortId ? `?cohort_id=${cohortId}` : ''; + return apiFetch(`/courses/${id}/analytics${query}`, {}, true); + }, + getAdvancedAnalytics: (id: string, cohortId?: string): Promise => { + const query = cohortId ? `?cohort_id=${cohortId}` : ''; + return apiFetch(`/courses/${id}/analytics/advanced${query}`, {}, true); + }, getLessonHeatmap: (lessonId: string): Promise<{ second: number, count: number }[]> => apiFetch(`/lessons/${lessonId}/heatmap`), exportCourse: (id: string): Promise> => apiFetch(`/courses/${id}/export`), importCourse: (data: Record): Promise => apiFetch(`/courses/import`, { @@ -489,6 +504,10 @@ export const lmsApi = { addMember: (cohortId: string, userId: string): Promise => apiFetch(`/cohorts/${cohortId}/members`, { method: 'POST', body: JSON.stringify({ user_id: userId }) }, true), removeMember: (cohortId: string, userId: string): Promise => apiFetch(`/cohorts/${cohortId}/members/${userId}`, { method: 'DELETE' }, true), getMembers: (id: string): Promise => apiFetch(`/cohorts/${id}/members`, {}, true), + getCourseGrades: (id: string, cohortId?: string): Promise => { + const query = cohortId ? `?cohort_id=${cohortId}` : ''; + return apiFetch(`/courses/${id}/grades${query}`, {}, true); + }, }; export interface BackgroundTask {