feat: Implement course-level asset management and interactive media markers.
This commit is contained in:
@@ -2,7 +2,10 @@ use chrono::{DateTime, Utc};
|
||||
use common::models::{Course, Lesson, Module};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::io::Cursor;
|
||||
use std::io::Write;
|
||||
use uuid::Uuid;
|
||||
use zip::write::FileOptions;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CourseExport {
|
||||
@@ -63,3 +66,50 @@ pub async fn get_course_data(pool: &PgPool, course_id: Uuid) -> anyhow::Result<C
|
||||
exported_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn generate_course_zip(pool: &PgPool, course_id: Uuid) -> anyhow::Result<Vec<u8>> {
|
||||
let course_data = get_course_data(pool, course_id).await?;
|
||||
let assets =
|
||||
sqlx::query_as::<_, common::models::Asset>("SELECT * FROM assets WHERE course_id = $1")
|
||||
.bind(course_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(&mut buf));
|
||||
|
||||
let options = FileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored)
|
||||
.unix_permissions(0o755);
|
||||
|
||||
// 1. Add course.json
|
||||
zip.start_file("course.json", options)?;
|
||||
let json_bytes = serde_json::to_vec_pretty(&course_data)?;
|
||||
zip.write_all(&json_bytes)?;
|
||||
|
||||
// 2. Add Assets
|
||||
for asset in assets {
|
||||
// Use the internal storage filename (UUID.ext) to avoid collisions
|
||||
let storage_filename = asset
|
||||
.storage_path
|
||||
.split('/')
|
||||
.last()
|
||||
.unwrap_or(&asset.filename);
|
||||
let zip_path = format!("assets/{}", storage_filename);
|
||||
|
||||
if let Ok(file_content) = tokio::fs::read(&asset.storage_path).await {
|
||||
zip.start_file(zip_path, options)?;
|
||||
zip.write_all(&file_content)?;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Failed to read asset file for export: {}",
|
||||
asset.storage_path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
zip.finish()?;
|
||||
drop(zip);
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -140,6 +140,8 @@ async fn main() {
|
||||
.route("/users/{id}", axum::routing::put(handlers::update_user))
|
||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||
.route("/assets/upload", post(handlers::upload_asset))
|
||||
.route("/assets/{id}", delete(handlers::delete_asset))
|
||||
.route("/courses/{id}/assets", get(handlers::get_course_assets))
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.route(
|
||||
"/organizations",
|
||||
|
||||
Reference in New Issue
Block a user