feat: implement server-side detailed course grade export to CSV, replacing client-side generation and adding a new API endpoint.

This commit is contained in:
2026-02-17 22:53:56 -03:00
parent 4c96d6b225
commit fa52397330
4 changed files with 153 additions and 20 deletions
+118
View File
@@ -2,6 +2,7 @@ use axum::{
Json, Json,
extract::{Multipart, Path, Query, State}, extract::{Multipart, Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse,
}; };
use bcrypt::{DEFAULT_COST, hash, verify}; use bcrypt::{DEFAULT_COST, hash, verify};
use common::auth::{Claims, create_jwt}; 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<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
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<Uuid>,
avg_score: Option<f32>,
}
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( pub async fn enroll_user(
Org(org_ctx): Org, Org(org_ctx): Org,
claims: Claims, claims: Claims,
+4
View File
@@ -69,6 +69,10 @@ async fn main() {
get(handlers::get_course_analytics), get(handlers::get_course_analytics),
) )
.route("/courses/{id}/grades", get(handlers::get_course_grades)) .route("/courses/{id}/grades", get(handlers::get_course_grades))
.route(
"/courses/{id}/export-grades",
get(handlers::export_course_grades),
)
.route( .route(
"/courses/{id}/analytics/advanced", "/courses/{id}/analytics/advanced",
get(handlers::get_advanced_analytics), get(handlers::get_advanced_analytics),
+23 -20
View File
@@ -84,26 +84,29 @@ export default function GradebookPage() {
? students.reduce((acc, s) => acc + (s.average_score || 0), 0) / students.length ? students.reduce((acc, s) => acc + (s.average_score || 0), 0) / students.length
: 0; : 0;
const exportCSV = () => { const exportCSV = async () => {
const headers = ["Name", "Email", "Progress", "Average Score", "Last Active"]; try {
const rows = filteredStudents.map(s => [ const token = localStorage.getItem('studio_token');
s.full_name, const selectedOrgId = localStorage.getItem('studio_selected_org_id');
s.email, const res = await fetch(lmsApi.exportGradesUrl(id), {
`${(s.progress * 100).toFixed(1)}%`, headers: {
s.average_score ? `${(s.average_score * 100).toFixed(1)}%` : "N/A", ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
s.last_active_at ? new Date(s.last_active_at).toLocaleDateString() : "Never" ...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
]); }
});
const csvContent = "data:text/csv;charset=utf-8," if (!res.ok) throw new Error('Export failed');
+ [headers.join(","), ...rows.map(r => r.join(","))].join("\n"); const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const encodedUri = encodeURI(csvContent); const a = document.createElement('a');
const link = document.createElement("a"); a.href = url;
link.setAttribute("href", encodedUri); a.download = `${courseTitle}_grades_detailed.csv`;
link.setAttribute("download", `${courseTitle}_gradebook.csv`); document.body.appendChild(a);
document.body.appendChild(link); a.click();
link.click(); window.URL.revokeObjectURL(url);
document.body.removeChild(link); document.body.removeChild(a);
} catch (err) {
console.error("Export failed", err);
}
}; };
const handleBulkEnroll = async () => { const handleBulkEnroll = async () => {
+8
View File
@@ -744,6 +744,14 @@ export const lmsApi = {
const query = cohortId ? `?cohort_id=${cohortId}` : ''; const query = cohortId ? `?cohort_id=${cohortId}` : '';
return apiFetch(`/courses/${id}/grades${query}`, {}, true); return apiFetch(`/courses/${id}/grades${query}`, {}, true);
}, },
exportGradesUrl: (courseId: string): string => {
const token = getToken();
// Since we are downloading via <a> 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<BulkEnrollResponse> => bulkEnroll: (courseId: string, emails: string[]): Promise<BulkEnrollResponse> =>
apiFetch('/bulk-enroll', { method: 'POST', body: JSON.stringify({ course_id: courseId, emails }) }, true), apiFetch('/bulk-enroll', { method: 'POST', body: JSON.stringify({ course_id: courseId, emails }) }, true),
// Peer Assessment // Peer Assessment