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,
|
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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user