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:
@@ -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<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(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 <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> =>
|
||||
apiFetch('/bulk-enroll', { method: 'POST', body: JSON.stringify({ course_id: courseId, emails }) }, true),
|
||||
// Peer Assessment
|
||||
|
||||
Reference in New Issue
Block a user