feat: Implement course-level asset management and interactive media markers.

This commit is contained in:
2026-01-17 13:55:04 -03:00
parent 0772a88fbe
commit 02909ea85a
15 changed files with 1027 additions and 182 deletions
+213 -17
View File
@@ -1526,6 +1526,7 @@ pub async fn upload_asset(
let mut filename = String::new();
let mut data = Vec::new();
let mut mimetype = String::new();
let mut course_id: Option<Uuid> = None;
while let Some(field) =
multipart
@@ -1549,6 +1550,12 @@ pub async fn upload_asset(
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?
.to_vec();
} else if name == "course_id" {
if let Ok(txt) = field.text().await {
if let Ok(id) = Uuid::parse_str(&txt) {
course_id = Some(id);
}
}
}
}
@@ -1582,7 +1589,7 @@ pub async fn upload_asset(
.unwrap_or(0);
sqlx::query(
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id) VALUES ($1, $2, $3, $4, $5, $6)"
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id, course_id) VALUES ($1, $2, $3, $4, $5, $6, $7)"
)
.bind(asset_id)
.bind(&filename)
@@ -1590,6 +1597,7 @@ pub async fn upload_asset(
.bind(mimetype)
.bind(size_bytes)
.bind(org_ctx.id)
.bind(course_id)
.execute(&pool)
.await
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -1604,6 +1612,93 @@ pub async fn upload_asset(
}))
}
pub async fn get_course_assets(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<Vec<common::models::Asset>>, StatusCode> {
let assets = sqlx::query_as::<_, common::models::Asset>(
"SELECT * FROM assets WHERE organization_id = $1 AND course_id = $2 ORDER BY created_at DESC"
)
.bind(org_ctx.id)
.bind(course_id)
.fetch_all(&pool)
.await
.map_err(|e| {
tracing::error!("Failed to fetch course assets: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(assets))
}
pub async fn delete_asset(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path(asset_id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
// 1. Fetch asset to verify ownership/org
let asset = sqlx::query_as::<_, common::models::Asset>(
"SELECT * FROM assets WHERE id = $1 AND organization_id = $2",
)
.bind(asset_id)
.bind(org_ctx.id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let asset = match asset {
Some(a) => a,
None => return Err((StatusCode::NOT_FOUND, "Asset not found".to_string())),
};
// 2. Check permissions (only instructor of the course or admin)
if claims.role != "admin" {
// If linked to a course, check if user owns that course
if let Some(cid) = asset.course_id {
let course = sqlx::query_as::<_, Course>("SELECT * FROM courses WHERE id = $1")
.bind(cid)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(c) = course {
if c.instructor_id != claims.sub {
return Err((
StatusCode::FORBIDDEN,
"Not authorized to delete this asset".to_string(),
));
}
}
}
// If not linked to a course, only admins might delete? Or maybe uploader?
// For now, let's assume if it's orphaned, only admin deletes.
if asset.course_id.is_none() {
return Err((
StatusCode::FORBIDDEN,
"Only admins can delete global assets".to_string(),
));
}
}
// 3. Delete file
// Note: storage_path is relative to working dir usually "uploads/..."
if let Err(e) = tokio::fs::remove_file(&asset.storage_path).await {
tracing::warn!("Failed to delete file {}: {}", asset.storage_path, e);
// We continue to delete from DB even if file specific deletion failed (maybe already gone)
}
// 4. Delete from DB
sqlx::query("DELETE FROM assets WHERE id = $1")
.bind(asset_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct AuthPayload {
pub email: String,
@@ -2706,7 +2801,7 @@ pub async fn export_course(
_claims: Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<exporter::CourseExport>, StatusCode> {
) -> Result<impl axum::response::IntoResponse, (StatusCode, String)> {
// 1. Verify access (ensure course belongs to org)
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM courses WHERE id = $1 AND organization_id = $2)",
@@ -2715,33 +2810,134 @@ pub async fn export_course(
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !exists {
return Err(StatusCode::NOT_FOUND);
}
// 2. Export recursively
let export = exporter::get_course_data(&pool, id).await.map_err(|e| {
tracing::error!("Export failed: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"DB Check failed".to_string(),
)
})?;
Ok(Json(export))
if !exists {
return Err((StatusCode::NOT_FOUND, "Course not found".to_string()));
}
// 2. Generate ZIP
let zip_bytes = exporter::generate_course_zip(&pool, id)
.await
.map_err(|e| {
tracing::error!("Export failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
})?;
let filename = format!("course-{}.ccb", id);
let disposition = format!("attachment; filename=\"{}\"", filename);
axum::response::Response::builder()
.header(axum::http::header::CONTENT_TYPE, "application/zip")
.header(axum::http::header::CONTENT_DISPOSITION, disposition)
.body(axum::body::Body::from(zip_bytes))
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to build response".to_string(),
)
})
}
#[axum::debug_handler]
pub async fn import_course(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<exporter::CourseExport>,
mut multipart: axum::extract::Multipart,
) -> Result<Json<Course>, StatusCode> {
// 1. Buffer the uploaded ZIP file
let mut zip_data = Vec::new();
while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? {
if field.name() == Some("file") {
zip_data = field.bytes().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?.to_vec();
break;
}
}
if zip_data.is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
// 2. Open ZIP
let reader = std::io::Cursor::new(zip_data);
let mut archive = zip::ZipArchive::new(reader).map_err(|_| StatusCode::BAD_REQUEST)?;
// 3. Process Assets & Prepare Remapping
let mut asset_map = std::collections::HashMap::new(); // Old Filename -> New URL
let len = archive.len();
for i in 0..len {
let (old_filename, content) = {
let mut file = archive.by_index(i).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !file.name().starts_with("assets/") || !file.is_file() {
continue;
}
let old_filename = file.name().trim_start_matches("assets/").to_string();
// Read content
let mut content = Vec::new();
std::io::Read::read_to_end(&mut file, &mut content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
(old_filename, content)
}; // file is dropped here
// Generate New ID and Path
let new_id = Uuid::new_v4();
let extension = std::path::Path::new(&old_filename).extension().and_then(|s| s.to_str()).unwrap_or("");
let new_storage_filename = format!("{}.{}", new_id, extension);
let new_storage_path = format!("uploads/{}", new_storage_filename);
let new_url = format!("/assets/{}", new_storage_filename);
// Write to Disk
tokio::fs::create_dir_all("uploads").await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tokio::fs::write(&new_storage_path, &content).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Get mimetype (guess or simple)
let mimetype = mime_guess::from_path(&old_filename).first_or_octet_stream().to_string();
sqlx::query(
"INSERT INTO assets (id, filename, storage_path, mimetype, size_bytes, organization_id)
VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(new_id)
.bind(&old_filename)
.bind(&new_storage_path)
.bind(&mimetype)
.bind(content.len() as i64)
.bind(org_ctx.id)
.execute(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
asset_map.insert(format!("/assets/{}", old_filename), new_url);
}
// 4. Read Course JSON and Remap
let mut course_json_str = String::new();
{
let mut json_file = archive.by_name("course.json").map_err(|_| StatusCode::BAD_REQUEST)?;
std::io::Read::read_to_string(&mut json_file, &mut course_json_str).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
// Apply Replacements
for (old_url, new_url) in &asset_map {
course_json_str = course_json_str.replace(old_url, new_url);
}
let payload: exporter::CourseExport = serde_json::from_str(&course_json_str).map_err(|_| StatusCode::BAD_REQUEST)?;
let mut tx = pool
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// 1. Create Course
// 5. Create Course
let new_course = sqlx::query_as::<_, Course>(
"INSERT INTO courses (
organization_id, instructor_id, title, pacing_mode, description,
@@ -2765,7 +2961,7 @@ pub async fn import_course(
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 2. Import Grading Categories and create mapping
// 6. Import Grading Categories
let mut cat_map = std::collections::HashMap::new();
for old_cat in payload.grading_categories {
let new_cat = sqlx::query_as::<_, common::models::GradingCategory>(
@@ -2785,7 +2981,7 @@ pub async fn import_course(
cat_map.insert(old_cat.id, new_cat.id);
}
// 3. Import Modules & Lessons
// 7. Import Modules & Lessons
for module_data in payload.modules {
let new_module = sqlx::query_as::<_, Module>(
"INSERT INTO modules (course_id, organization_id, title, position)