api/routes/modules/assignments/submissions/
post.rs

1use super::common::{CodeComplexity, CodeComplexitySummary, MarkSummary, SubmissionDetailResponse};
2use crate::{auth::AuthUser, response::ApiResponse, routes::modules::assignments::get::is_late};
3use axum::{
4    Json,
5    extract::{Extension, Multipart, Path, State},
6    http::StatusCode,
7    response::IntoResponse,
8};
9use chrono::Utc;
10use code_runner;
11use db::models::{
12    assignment::{Column as AssignmentColumn, Entity as AssignmentEntity},
13    assignment_submission::{self, Model as AssignmentSubmissionModel},
14};
15use marker::MarkingJob;
16use md5;
17use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
18use serde::{Deserialize, Serialize};
19use tokio_util::bytes;
20use util::{
21    execution_config::{ExecutionConfig, execution_config::SubmissionMode},
22    mark_allocator::mark_allocator::generate_allocator,
23    mark_allocator::mark_allocator::load_allocator,
24    state::AppState,
25};
26
27#[derive(Debug, Deserialize)]
28pub struct RemarkRequest {
29    #[serde(default)]
30    submission_ids: Option<Vec<i64>>,
31    #[serde(default)]
32    all: Option<bool>,
33}
34
35#[derive(Debug, Deserialize)]
36pub struct ResubmitRequest {
37    #[serde(default)]
38    submission_ids: Option<Vec<i64>>,
39    #[serde(default)]
40    all: Option<bool>,
41}
42
43#[derive(Debug, Serialize)]
44pub struct BulkOperationResponse {
45    processed: usize,
46    failed: Vec<FailedOperation>,
47}
48
49#[derive(Debug, Serialize)]
50pub struct RemarkResponse {
51    regraded: usize,
52    failed: Vec<FailedOperation>,
53}
54
55#[derive(Debug, Serialize)]
56pub struct ResubmitResponse {
57    resubmitted: usize,
58    failed: Vec<FailedOperation>,
59}
60
61#[derive(Debug, Serialize)]
62pub struct FailedOperation {
63    id: Option<i64>,
64    error: String,
65}
66
67// ============================================================================
68// Helper Functions
69// ============================================================================
70
71/// Validates bulk operation request ensuring exactly one of submission_ids or all is provided
72fn validate_bulk_request(
73    submission_ids: &Option<Vec<i64>>,
74    all: &Option<bool>,
75) -> Result<(), &'static str> {
76    match (submission_ids, all) {
77        (Some(ids), None) if !ids.is_empty() => Ok(()),
78        (None, Some(true)) => Ok(()),
79        (Some(_), None) => Err("submission_ids cannot be empty"),
80        _ => Err("Must provide exactly one of submission_ids or all=true"),
81    }
82}
83
84/// Resolves target submission IDs based on request parameters
85async fn resolve_submission_ids(
86    submission_ids: Option<Vec<i64>>,
87    all: Option<bool>,
88    assignment_id: i64,
89    db: &sea_orm::DatabaseConnection,
90) -> Result<Vec<i64>, String> {
91    match (submission_ids, all) {
92        (Some(ids), _) if !ids.is_empty() => Ok(ids),
93        (_, Some(true)) => AssignmentSubmissionModel::find_by_assignment(assignment_id, db)
94            .await
95            .map_err(|e| format!("Failed to fetch submissions: {}", e)),
96        _ => Err("Must provide either submission_ids or all=true".to_string()),
97    }
98}
99
100/// Loads assignment and validates it exists for the given module
101async fn load_assignment(
102    module_id: i64,
103    assignment_id: i64,
104    db: &sea_orm::DatabaseConnection,
105) -> Result<db::models::assignment::Model, String> {
106    AssignmentEntity::find()
107        .filter(AssignmentColumn::Id.eq(assignment_id))
108        .filter(AssignmentColumn::ModuleId.eq(module_id))
109        .one(db)
110        .await
111        .map_err(|e| format!("Database error: {}", e))?
112        .ok_or_else(|| "Assignment not found".to_string())
113}
114
115/// Loads the mark allocator for an assignment
116async fn load_assignment_allocator(module_id: i64, assignment_id: i64) -> Result<(), String> {
117    load_allocator(module_id, assignment_id)
118        .await
119        .map(|_| ())
120        .map_err(|_| "Failed to load mark allocator".to_string())
121}
122
123/// Gets assignment file paths and configurations
124fn get_assignment_paths(
125    module_id: i64,
126    assignment_id: i64,
127) -> Result<
128    (
129        std::path::PathBuf,
130        std::path::PathBuf,
131        Vec<std::path::PathBuf>,
132    ),
133    String,
134> {
135    let assignment_storage_root = std::env::var("ASSIGNMENT_STORAGE_ROOT")
136        .unwrap_or_else(|_| "data/assignment_files".to_string());
137
138    let base_path = std::path::PathBuf::from(&assignment_storage_root)
139        .join(format!("module_{}", module_id))
140        .join(format!("assignment_{}", assignment_id));
141
142    let mark_allocator_path = base_path.join("mark_allocator/allocator.json");
143    let memo_output_dir = base_path.join("memo_output");
144
145    let mut memo_outputs: Vec<_> = match std::fs::read_dir(&memo_output_dir) {
146        Ok(rd) => rd
147            .filter_map(|e| e.ok().map(|e| e.path()))
148            .filter(|p| p.is_file())
149            .collect(),
150        Err(_) => Vec::new(),
151    };
152    memo_outputs.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
153
154    Ok((base_path, mark_allocator_path, memo_outputs))
155}
156
157/// Loads execution configuration for an assignment
158fn get_execution_config(module_id: i64, assignment_id: i64) -> Result<ExecutionConfig, String> {
159    ExecutionConfig::get_execution_config(module_id, assignment_id)
160        .map_err(|_| "Failed to load execution config".to_string())
161}
162
163/// Validates file upload requirements
164fn validate_file_upload(
165    file_name: &Option<String>,
166    file_bytes: &Option<bytes::Bytes>,
167) -> Result<(String, bytes::Bytes), (StatusCode, Json<ApiResponse<SubmissionDetailResponse>>)> {
168    let file_name = match file_name {
169        Some(name) => name.clone(),
170        None => {
171            return Err((
172                StatusCode::UNPROCESSABLE_ENTITY,
173                Json(ApiResponse::<SubmissionDetailResponse>::error(
174                    "No file provided",
175                )),
176            ));
177        }
178    };
179
180    let file_bytes = match file_bytes {
181        Some(bytes) => bytes.clone(),
182        None => {
183            return Err((
184                StatusCode::UNPROCESSABLE_ENTITY,
185                Json(ApiResponse::<SubmissionDetailResponse>::error(
186                    "No file provided",
187                )),
188            ));
189        }
190    };
191
192    let allowed_extensions = [".tgz", ".gz", ".tar", ".zip"];
193    let file_extension = std::path::Path::new(&file_name)
194        .extension()
195        .and_then(|ext| ext.to_str())
196        .map(|ext| format!(".{}", ext.to_lowercase()));
197
198    if !file_extension
199        .as_ref()
200        .map_or(false, |ext| allowed_extensions.contains(&ext.as_str()))
201    {
202        return Err((
203            StatusCode::UNPROCESSABLE_ENTITY,
204            Json(ApiResponse::<SubmissionDetailResponse>::error(
205                "Only .tgz, .gz, .tar, and .zip files are allowed",
206            )),
207        ));
208    }
209
210    if file_bytes.is_empty() {
211        return Err((
212            StatusCode::UNPROCESSABLE_ENTITY,
213            Json(ApiResponse::<SubmissionDetailResponse>::error(
214                "Empty file provided",
215            )),
216        ));
217    }
218
219    Ok((file_name, file_bytes))
220}
221
222/// Gets the next attempt number for a user's assignment
223async fn get_next_attempt(
224    assignment_id: i64,
225    user_id: i64,
226    db: &sea_orm::DatabaseConnection,
227) -> Result<i64, String> {
228    let prev_attempt = assignment_submission::Entity::find()
229        .filter(assignment_submission::Column::AssignmentId.eq(assignment_id))
230        .filter(assignment_submission::Column::UserId.eq(user_id))
231        .order_by_desc(assignment_submission::Column::Attempt)
232        .one(db)
233        .await
234        .map_err(|e| format!("Database error: {}", e))?
235        .map(|s| s.attempt)
236        .unwrap_or(0);
237
238    Ok(prev_attempt + 1)
239}
240
241/// Core grading function that can be used for initial submissions, regrading, and resubmission
242async fn grade_submission(
243    submission: AssignmentSubmissionModel,
244    assignment: &db::models::assignment::Model,
245    base_path: &std::path::Path,
246    memo_outputs: &[std::path::PathBuf],
247    mark_allocator_path: &std::path::Path,
248    config: &util::execution_config::ExecutionConfig,
249    db: &sea_orm::DatabaseConnection,
250) -> Result<SubmissionDetailResponse, String> {
251    let student_output_dir = base_path
252        .join("assignment_submissions")
253        .join(format!("user_{}", submission.user_id))
254        .join(format!("attempt_{}", submission.attempt))
255        .join("submission_output");
256
257    let mut student_outputs = Vec::new();
258    if let Ok(entries) = std::fs::read_dir(&student_output_dir) {
259        for entry in entries.flatten() {
260            let file_path = entry.path();
261            if file_path.is_file() {
262                if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
263                    if ext.eq_ignore_ascii_case("txt") {
264                        student_outputs.push(file_path);
265                    }
266                }
267            }
268        }
269    }
270    student_outputs.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
271
272    let marking_job = MarkingJob::new(
273        memo_outputs.to_vec(),
274        student_outputs,
275        mark_allocator_path.to_path_buf(),
276        config.clone(),
277    );
278
279    let mark_report = marking_job.mark().await.map_err(|e| {
280        eprintln!("MARKING ERROR: {:#?}", e);
281        format!("{:?}", e)
282    })?;
283
284    let mark = MarkSummary {
285        earned: mark_report.data.mark.earned,
286        total: mark_report.data.mark.total,
287    };
288    let tasks = serde_json::to_value(&mark_report.data.tasks)
289        .unwrap_or_default()
290        .as_array()
291        .cloned()
292        .unwrap_or_default();
293    let code_coverage = match &mark_report.data.code_coverage {
294        Some(cov) => {
295            let arr = serde_json::to_value(cov)
296                .unwrap_or_default()
297                .as_array()
298                .cloned()
299                .unwrap_or_default();
300            if !arr.is_empty() { Some(arr) } else { None }
301        }
302        None => None,
303    };
304    let code_complexity = match &mark_report.data.code_complexity {
305        Some(c) => {
306            let metrics = serde_json::to_value(&c.metrics)
307                .unwrap_or_default()
308                .as_array()
309                .cloned()
310                .unwrap_or_default();
311            let summary = CodeComplexitySummary {
312                earned: c.summary.as_ref().map(|s| s.earned).unwrap_or(0),
313                total: c.summary.as_ref().map(|s| s.total).unwrap_or(0),
314            };
315            if !metrics.is_empty() || summary.earned != 0 || summary.total != 0 {
316                Some(CodeComplexity { summary, metrics })
317            } else {
318                None
319            }
320        }
321        None => None,
322    };
323
324    let mut active_model: assignment_submission::ActiveModel = submission.clone().into();
325    active_model.earned = sea_orm::ActiveValue::Set(mark.earned);
326    active_model.total = sea_orm::ActiveValue::Set(mark.total);
327    assignment_submission::Entity::update(active_model)
328        .exec(db)
329        .await
330        .map_err(|e| e.to_string())?;
331
332    let now = Utc::now();
333    let resp = SubmissionDetailResponse {
334        id: submission.id,
335        attempt: submission.attempt,
336        filename: submission.filename.clone(),
337        hash: submission.file_hash.clone(),
338        created_at: now.to_rfc3339(),
339        updated_at: now.to_rfc3339(),
340        mark,
341        is_practice: submission.is_practice,
342        is_late: is_late(submission.created_at, assignment.due_date),
343        tasks,
344        code_coverage,
345        code_complexity,
346    };
347
348    let attempt_dir = base_path
349        .join("assignment_submissions")
350        .join(format!("user_{}", submission.user_id))
351        .join(format!("attempt_{}", submission.attempt));
352    let report_path = attempt_dir.join("submission_report.json");
353    if let Ok(json) = serde_json::to_string_pretty(&resp) {
354        std::fs::write(&report_path, json).map_err(|e| e.to_string())?;
355    } else {
356        return Err("Failed to serialize submission report".to_string());
357    }
358
359    Ok(resp)
360}
361
362/// Processes code execution for a submission
363async fn process_submission_code(
364    db: &sea_orm::DatabaseConnection,
365    submission_id: i64,
366    config: ExecutionConfig,
367    module_id: i64,
368    assignment_id: i64,
369) -> Result<(), String> {
370    if config.project.submission_mode == SubmissionMode::Manual.clone() {
371        code_runner::create_submission_outputs_for_all_tasks(db, submission_id)
372            .await
373            .map_err(|e| format!("Code runner failed: {}", e))
374    } else {
375        ai::run_ga_job(db, submission_id, config, module_id, assignment_id)
376            .await
377            .map_err(|e| format!("GATLAM failed: {}", e))
378    }
379}
380
381/// Clears the submission output directory
382fn clear_submission_output(
383    submission: &AssignmentSubmissionModel,
384    base_path: &std::path::Path,
385) -> Result<(), String> {
386    let attempt_dir = base_path
387        .join("assignment_submissions")
388        .join(format!("user_{}", submission.user_id))
389        .join(format!("attempt_{}", submission.attempt));
390
391    let output_dir = attempt_dir.join("submission_output");
392
393    if output_dir.exists() {
394        std::fs::remove_dir_all(&output_dir)
395            .map_err(|e| format!("Failed to clear output directory: {}", e))?;
396    }
397
398    let report_path = attempt_dir.join("submission_report.json");
399    if report_path.exists() {
400        std::fs::remove_file(&report_path)
401            .map_err(|e| format!("Failed to remove existing report: {}", e))?;
402    }
403
404    Ok(())
405}
406
407/// Read JSON into `SubmissionDetailResponse`, mutate, and write atomically.
408async fn update_submission_report_marks(
409    base_path: &std::path::Path,
410    submission: &AssignmentSubmissionModel,
411    new_mark: &MarkSummary,
412) -> Result<(), String> {
413    let attempt_dir = base_path
414        .join("assignment_submissions")
415        .join(format!("user_{}", submission.user_id))
416        .join(format!("attempt_{}", submission.attempt));
417    let report_path = attempt_dir.join("submission_report.json");
418
419    let content = std::fs::read_to_string(&report_path)
420        .map_err(|e| format!("Failed to read existing report: {}", e))?;
421
422    let mut resp: SubmissionDetailResponse = serde_json::from_str(&content).map_err(|e| {
423        format!(
424            "Failed to deserialize report into SubmissionDetailResponse: {}",
425            e
426        )
427    })?;
428
429    resp.mark = MarkSummary {
430        earned: new_mark.earned,
431        total: new_mark.total,
432    };
433
434    resp.updated_at = Utc::now().to_rfc3339();
435
436    let output = serde_json::to_string_pretty(&resp)
437        .map_err(|e| format!("Failed to serialize updated report: {}", e))?;
438    std::fs::write(&report_path, output)
439        .map_err(|e| format!("Failed to write temp report: {}", e))?;
440
441    Ok(())
442}
443
444/// Executes bulk operation on submissions (remark or resubmit)
445async fn execute_bulk_operation<F, Fut>(
446    submission_ids: Vec<i64>,
447    assignment_id: i64,
448    db: &sea_orm::DatabaseConnection,
449    operation: F,
450) -> (usize, Vec<FailedOperation>)
451where
452    F: Fn(AssignmentSubmissionModel) -> Fut,
453    Fut: std::future::Future<Output = Result<(), String>>,
454{
455    let mut processed = 0;
456    let mut failed = Vec::new();
457
458    for submission_id in submission_ids {
459        let submission = match assignment_submission::Entity::find_by_id(submission_id)
460            .one(db)
461            .await
462        {
463            Ok(Some(sub)) => sub,
464            Ok(None) => {
465                failed.push(FailedOperation {
466                    id: Some(submission_id),
467                    error: "Submission not found".to_string(),
468                });
469                continue;
470            }
471            Err(e) => {
472                failed.push(FailedOperation {
473                    id: Some(submission_id),
474                    error: format!("Database error: {}", e),
475                });
476                continue;
477            }
478        };
479
480        if submission.assignment_id != assignment_id {
481            failed.push(FailedOperation {
482                id: Some(submission_id),
483                error: "Submission does not belong to this assignment".to_string(),
484            });
485            continue;
486        }
487
488        match operation(submission).await {
489            Ok(_) => processed += 1,
490            Err(e) => failed.push(FailedOperation {
491                id: Some(submission_id),
492                error: e,
493            }),
494        }
495    }
496
497    (processed, failed)
498}
499
500// ============================================================================
501// Route Handlers
502// ============================================================================
503
504/// POST /api/modules/{module_id}/assignments/{assignment_id}/submissions
505///
506/// Submit an assignment file for grading. Accessible to authenticated students assigned to the module.
507///
508/// This endpoint accepts a multipart form upload containing the assignment file and optional flags.
509/// The file is saved, graded, and a detailed grading report is returned. The grading process includes
510/// code execution, mark allocation, and optional code coverage/complexity analysis.
511///
512/// ### Path Parameters
513/// - `module_id` (i64): The ID of the module containing the assignment
514/// - `assignment_id` (i64): The ID of the assignment to submit to
515///
516/// ### Request (multipart/form-data)
517/// - `file` (required): The assignment file to upload (`.tgz`, `.gz`, `.tar`, or `.zip` only)
518/// - `is_practice` (optional): If set to `true` or `1`, marks this as a practice submission
519///
520/// ### Example Request
521/// ```bash
522/// curl -X POST http://localhost:3000/api/modules/1/assignments/2/submissions \
523///   -H "Authorization: Bearer <token>" \
524///   -F "[email protected]" \
525///   -F "is_practice=true"
526/// ```
527///
528/// ### Success Response (200 OK)
529/// ```json
530/// {
531///   "success": true,
532///   "message": "Submission received and graded",
533///   "data": {
534///     "id": 123,
535///     "attempt": 2,
536///     "filename": "solution.zip",
537///     "hash": "d41d8cd98f00b204e9800998ecf8427e",
538///     "created_at": "2024-01-15T10:30:00Z",
539///     "updated_at": "2024-01-15T10:30:00Z",
540///     "mark": { "earned": 85, "total": 100 },
541///     "is_practice": true,
542///     "is_late": false,
543///     "tasks": [ ... ],
544///     "code_coverage": [ ... ],
545///     "code_complexity": {
546///       "summary": { "earned": 10, "total": 15 },
547///       "metrics": [ ... ]
548///     }
549///   }
550/// }
551/// ```
552///
553/// ### Error Responses
554///
555/// **404 Not Found** - Assignment not found
556/// ```json
557/// { "success": false, "message": "Assignment not found" }
558/// ```
559///
560/// **422 Unprocessable Entity** - File missing, invalid, or empty
561/// ```json
562/// { "success": false, "message": "No file provided" }
563/// ```
564/// or
565/// ```json
566/// { "success": false, "message": "Only .tgz, .gz, .tar, and .zip files are allowed" }
567/// ```
568/// or
569/// ```json
570/// { "success": false, "message": "Empty file provided" }
571/// ```
572///
573/// **500 Internal Server Error** - Grading or system error
574/// ```json
575/// { "success": false, "message": "Failed to save submission" }
576/// ```
577/// or
578/// ```json
579/// { "success": false, "message": "Failed to run code for submission" }
580/// ```
581/// or
582/// ```json
583/// { "success": false, "message": "Failed to load mark allocator" }
584/// ```
585/// or
586/// ```json
587/// { "success": false, "message": "Failed to mark submission" }
588/// ```
589///
590/// ### Side Effects
591/// - Saves the uploaded file and generated outputs to disk
592/// - Triggers code execution and marking
593/// - Saves a copy of the grading report as `submission_report.json` in the attempt folder
594///
595/// ### Filesystem
596/// - Uploaded file and outputs are stored under:
597///   `ASSIGNMENT_STORAGE_ROOT/module_{module_id}/assignment_{assignment_id}/assignment_submissions/user_{user_id}/attempt_{n}/`
598///
599/// ### Notes
600/// - Each submission increments the attempt number for the user/assignment
601/// - Only one file per submission is accepted
602/// - Practice submissions are marked and reported but may not count toward final grade
603/// - The returned report includes detailed per-task grading, code coverage, and complexity if available
604/// - The endpoint is restricted to authenticated students assigned to the module
605/// - All errors are returned in a consistent JSON format
606pub async fn submit_assignment(
607    State(app_state): State<AppState>,
608    Path((module_id, assignment_id)): Path<(i64, i64)>,
609    Extension(AuthUser(claims)): Extension<AuthUser>,
610    mut multipart: Multipart,
611) -> impl IntoResponse {
612    let db = app_state.db();
613
614    let assignment = match load_assignment(module_id, assignment_id, db).await {
615        Ok(assignment) => assignment,
616        Err(_) => {
617            return (
618                StatusCode::NOT_FOUND,
619                Json(ApiResponse::<SubmissionDetailResponse>::error(
620                    "Assignment not found",
621                )),
622            );
623        }
624    };
625
626    let mut is_practice: bool = false;
627    let mut file_name: Option<String> = None;
628    let mut file_bytes: Option<bytes::Bytes> = None;
629
630    while let Some(field) = multipart.next_field().await.unwrap_or(None) {
631        match field.name() {
632            Some("file") => {
633                file_name = field.file_name().map(|s| s.to_string());
634                file_bytes = Some(field.bytes().await.unwrap_or_default());
635            }
636            Some("is_practice") => {
637                let val = field.text().await.unwrap_or_default();
638                is_practice = val == "true" || val == "1";
639            }
640            _ => {}
641        }
642    }
643
644    let (file_name, file_bytes) = match validate_file_upload(&file_name, &file_bytes) {
645        Ok((name, bytes)) => (name, bytes),
646        Err(response) => return response,
647    };
648
649    let file_hash = format!("{:x}", md5::compute(&file_bytes));
650
651    let attempt = match get_next_attempt(assignment_id, claims.sub, db).await {
652        Ok(attempt) => attempt,
653        Err(e) => {
654            eprintln!("Error getting next attempt: {}", e);
655            return (
656                StatusCode::INTERNAL_SERVER_ERROR,
657                Json(ApiResponse::<SubmissionDetailResponse>::error(
658                    "Failed to determine attempt number",
659                )),
660            );
661        }
662    };
663
664    let submission = match AssignmentSubmissionModel::save_file(
665        db,
666        assignment_id,
667        claims.sub,
668        attempt,
669        0,
670        0,
671        is_practice,
672        &file_name,
673        &file_hash,
674        &file_bytes,
675    )
676    .await
677    {
678        Ok(model) => model,
679        Err(e) => {
680            eprintln!("Error saving submission: {:?}", e);
681            return (
682                StatusCode::INTERNAL_SERVER_ERROR,
683                Json(ApiResponse::<SubmissionDetailResponse>::error(
684                    "Failed to save submission",
685                )),
686            );
687        }
688    };
689
690    let config = match get_execution_config(module_id, assignment_id) {
691        Ok(config) => config,
692        Err(e) => {
693            return (
694                StatusCode::INTERNAL_SERVER_ERROR,
695                Json(ApiResponse::<SubmissionDetailResponse>::error(&e)),
696            );
697        }
698    };
699
700    if let Err(e) =
701        process_submission_code(db, submission.id, config.clone(), module_id, assignment_id).await
702    {
703        eprintln!("Code execution failed: {}", e);
704        return (
705            StatusCode::INTERNAL_SERVER_ERROR,
706            Json(ApiResponse::<SubmissionDetailResponse>::error(
707                "Failed to run code for submission",
708            )),
709        );
710    }
711
712    if config.project.submission_mode != SubmissionMode::Manual {
713        if let Err(_) = generate_allocator(module_id, assignment_id).await {
714            return (
715                StatusCode::INTERNAL_SERVER_ERROR,
716                Json(ApiResponse::<SubmissionDetailResponse>::error(
717                    "Failed to generate allocator",
718                )),
719            );
720        }
721    }
722
723    if let Err(e) = load_assignment_allocator(assignment.module_id, assignment.id).await {
724        return (
725            StatusCode::INTERNAL_SERVER_ERROR,
726            Json(ApiResponse::<SubmissionDetailResponse>::error(&e)),
727        );
728    }
729
730    let (base_path, mark_allocator_path, memo_outputs) =
731        match get_assignment_paths(assignment.module_id, assignment.id) {
732            Ok(paths) => paths,
733            Err(e) => {
734                return (
735                    StatusCode::INTERNAL_SERVER_ERROR,
736                    Json(ApiResponse::<SubmissionDetailResponse>::error(&e)),
737                );
738            }
739        };
740
741    match grade_submission(
742        submission,
743        &assignment,
744        &base_path,
745        &memo_outputs,
746        &mark_allocator_path,
747        &config,
748        db,
749    )
750    .await
751    {
752        Ok(resp) => (
753            StatusCode::OK,
754            Json(ApiResponse::success(resp, "Submission received and graded")),
755        ),
756        Err(e) => (
757            StatusCode::INTERNAL_SERVER_ERROR,
758            Json(ApiResponse::<SubmissionDetailResponse>::error(e)),
759        ),
760    }
761}
762
763/// POST /api/modules/{module_id}/assignments/{assignment_id}/submissions/remark
764///
765/// Regrade (remark) assignment submissions. Accessible to lecturers, assistant lecturers, and admins.
766///
767/// This endpoint allows authorized users to re-run marking logic on either:
768/// - Specific submissions (via `submission_ids`)
769/// - All submissions in an assignment (via `all: true`)
770///
771/// ### Path Parameters
772/// - `module_id` (i64): The ID of the module containing the assignment
773/// - `assignment_id` (i64): The ID of the assignment containing the submissions
774///
775/// ### Request Body
776/// Either:
777/// ```json
778/// { "submission_ids": [123, 124, 125] }
779/// ```
780/// or
781/// ```json
782/// { "all": true }
783/// ```
784///
785/// ### Success Response (200 OK)
786/// ```json
787/// {
788///   "success": true,
789///   "message": "Regraded 3/4 submissions",
790///   "data": {
791///     "regraded": 3,
792///     "failed": [
793///       { "id": 125, "error": "Submission not found" }
794///     ]
795///   }
796/// }
797/// ```
798///
799/// ### Error Responses
800///
801/// **400 Bad Request** - Invalid request parameters
802/// ```json
803/// { "success": false, "message": "Must provide either submission_ids or all=true" }
804/// ```
805///
806/// **403 Forbidden** - User not authorized for operation
807/// ```json
808/// { "success": false, "message": "Not authorized to remark submissions" }
809/// ```
810///
811/// **404 Not Found** - Assignment not found
812/// ```json
813/// { "success": false, "message": "Assignment not found" }
814/// ```
815///
816/// **500 Internal Server Error** - Regrading failure
817/// ```json
818/// { "success": false, "message": "Failed to load mark allocator" }
819/// ```
820pub async fn remark_submissions(
821    State(app_state): State<AppState>,
822    Path((module_id, assignment_id)): Path<(i64, i64)>,
823    Json(req): Json<RemarkRequest>,
824) -> impl IntoResponse {
825    let db = app_state.db();
826
827    if let Err(e) = validate_bulk_request(&req.submission_ids, &req.all) {
828        return (
829            StatusCode::BAD_REQUEST,
830            Json(ApiResponse::<RemarkResponse>::error(e)),
831        );
832    }
833
834    let assignment = match load_assignment(module_id, assignment_id, db).await {
835        Ok(assignment) => assignment,
836        Err(_) => {
837            return (
838                StatusCode::NOT_FOUND,
839                Json(ApiResponse::<RemarkResponse>::error("Assignment not found")),
840            );
841        }
842    };
843
844    let submission_ids =
845        match resolve_submission_ids(req.submission_ids, req.all, assignment_id, db).await {
846            Ok(ids) => ids,
847            Err(e) => {
848                return (
849                    StatusCode::INTERNAL_SERVER_ERROR,
850                    Json(ApiResponse::<RemarkResponse>::error(e)),
851                );
852            }
853        };
854
855    if let Err(e) = load_assignment_allocator(assignment.module_id, assignment.id).await {
856        return (
857            StatusCode::INTERNAL_SERVER_ERROR,
858            Json(ApiResponse::<RemarkResponse>::error(e)),
859        );
860    }
861
862    let (base_path, mark_allocator_path, memo_outputs) =
863        match get_assignment_paths(assignment.module_id, assignment.id) {
864            Ok(paths) => paths,
865            Err(e) => {
866                return (
867                    StatusCode::INTERNAL_SERVER_ERROR,
868                    Json(ApiResponse::<RemarkResponse>::error(e)),
869                );
870            }
871        };
872
873    let config = match get_execution_config(module_id, assignment_id) {
874        Ok(config) => config,
875        Err(e) => {
876            return (
877                StatusCode::INTERNAL_SERVER_ERROR,
878                Json(ApiResponse::<RemarkResponse>::error(e)),
879            );
880        }
881    };
882
883    let (regraded, failed) =
884        execute_bulk_operation(submission_ids.clone(), assignment_id, db, |submission| {
885            let assignment = assignment.clone();
886            let base_path = base_path.clone();
887            let memo_outputs = memo_outputs.clone();
888            let mark_allocator_path = mark_allocator_path.clone();
889            let config = config.clone();
890            async move {
891                let student_output_dir = base_path
892                    .join("assignment_submissions")
893                    .join(format!("user_{}", submission.user_id))
894                    .join(format!("attempt_{}", submission.attempt))
895                    .join("submission_output");
896
897                let mut student_outputs = Vec::new();
898                if let Ok(entries) = std::fs::read_dir(&student_output_dir) {
899                    for entry in entries.flatten() {
900                        let file_path = entry.path();
901                        if file_path.is_file() {
902                            if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
903                                if ext.eq_ignore_ascii_case("txt") {
904                                    student_outputs.push(file_path);
905                                }
906                            }
907                        }
908                    }
909                }
910                student_outputs.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
911
912                let marking_job = MarkingJob::new(
913                    memo_outputs.to_vec(),
914                    student_outputs,
915                    mark_allocator_path.to_path_buf(),
916                    config.clone(),
917                );
918
919                let mark_report = marking_job
920                    .mark()
921                    .await
922                    .map_err(|e| format!("Marking error: {:?}", e))?;
923
924                let mark = MarkSummary {
925                    earned: mark_report.data.mark.earned,
926                    total: mark_report.data.mark.total,
927                };
928
929                match update_submission_report_marks(&base_path, &submission, &mark).await {
930                    Ok(_) => Ok(()),
931                    Err(_err) => grade_submission(
932                        submission,
933                        &assignment,
934                        &base_path,
935                        &memo_outputs,
936                        &mark_allocator_path,
937                        &config,
938                        db,
939                    )
940                    .await
941                    .map(|_| ()),
942                }
943            }
944        })
945        .await;
946
947    let response = RemarkResponse { regraded, failed };
948    let message = format!("Regraded {}/{} submissions", regraded, submission_ids.len());
949
950    (
951        StatusCode::OK,
952        Json(ApiResponse::success(response, &message)),
953    )
954}
955
956/// POST /api/modules/{module_id}/assignments/{assignment_id}/submissions/resubmit
957///
958/// Reprocess assignment submissions using the latest marking pipeline. Accessible to admins, module lecturers, and assistant lecturers.
959///
960/// This endpoint allows authorized users to rerun the entire submission pipeline (code execution + marking) on either:
961/// - Specific submissions (via `submission_ids`)
962/// - All submissions in an assignment (via `all: true`)
963///
964/// ### Path Parameters
965/// - `module_id` (i64): The ID of the module containing the assignment
966/// - `assignment_id` (i64): The ID of the assignment containing the submissions
967///
968/// ### Request Body
969/// Either:
970/// ```json
971/// { "submission_ids": [123, 124, 125] }
972/// ```
973/// or
974/// ```json
975/// { "all": true }
976/// ```
977///
978/// ### Success Response (200 OK)
979/// ```json
980/// {
981///   "success": true,
982///   "message": "Resubmitted 3/4 submissions",
983///   "data": {
984///     "resubmitted": 3,
985///     "failed": [
986///       { "id": 125, "error": "Submission not found" }
987///     ]
988///   }
989/// }
990/// ```
991///
992/// ### Error Responses
993///
994/// **400 Bad Request** - Invalid request parameters
995/// ```json
996/// { "success": false, "message": "Must provide exactly one of submission_ids or all=true" }
997/// ```
998/// or
999/// ```json
1000/// { "success": false, "message": "submission_ids cannot be empty" }
1001/// ```
1002///
1003/// **403 Forbidden** - User not authorized for operation
1004/// ```json
1005/// { "success": false, "message": "Not authorized to resubmit submissions" }
1006/// ```
1007///
1008/// **404 Not Found** - Assignment not found
1009/// ```json
1010/// { "success": false, "message": "Assignment not found" }
1011/// ```
1012///
1013/// **500 Internal Server Error** - Resubmission failure
1014/// ```json
1015/// { "success": false, "message": "Failed to load mark allocator" }
1016/// ```
1017/// or
1018/// ```json
1019/// { "success": false, "message": "Failed to run code for submission" }
1020/// ```
1021///
1022/// ### Side Effects
1023/// - Re-executes code for all target submissions
1024/// - Regenerates marking reports and saves updated `submission_report.json` files
1025/// - Updates submission status transitions as applicable
1026///
1027/// ### Notes
1028/// - Resubmission reruns the entire pipeline: code execution → marking → report generation
1029/// - This differs from remark which only reruns the marking phase
1030/// - The endpoint is restricted to admin, module lecturer, and assistant lecturer roles
1031/// - All errors are returned in a consistent JSON format with per-submission failure details
1032pub async fn resubmit_submissions(
1033    State(app_state): State<AppState>,
1034    Path((module_id, assignment_id)): Path<(i64, i64)>,
1035    Extension(AuthUser(_claims)): Extension<AuthUser>,
1036    Json(req): Json<ResubmitRequest>,
1037) -> impl IntoResponse {
1038    let db = app_state.db();
1039
1040    if let Err(e) = validate_bulk_request(&req.submission_ids, &req.all) {
1041        return (
1042            StatusCode::BAD_REQUEST,
1043            Json(ApiResponse::<ResubmitResponse>::error(e)),
1044        );
1045    }
1046
1047    let assignment = match load_assignment(module_id, assignment_id, db).await {
1048        Ok(assignment) => assignment,
1049        Err(_) => {
1050            return (
1051                StatusCode::NOT_FOUND,
1052                Json(ApiResponse::<ResubmitResponse>::error(
1053                    "Assignment not found",
1054                )),
1055            );
1056        }
1057    };
1058
1059    let submission_ids =
1060        match resolve_submission_ids(req.submission_ids, req.all, assignment_id, db).await {
1061            Ok(ids) => ids,
1062            Err(e) => {
1063                return (
1064                    StatusCode::INTERNAL_SERVER_ERROR,
1065                    Json(ApiResponse::<ResubmitResponse>::error(e)),
1066                );
1067            }
1068        };
1069
1070    if let Err(e) = load_assignment_allocator(assignment.module_id, assignment.id).await {
1071        return (
1072            StatusCode::INTERNAL_SERVER_ERROR,
1073            Json(ApiResponse::<ResubmitResponse>::error(e)),
1074        );
1075    }
1076
1077    let (base_path, mark_allocator_path, memo_outputs) =
1078        match get_assignment_paths(assignment.module_id, assignment.id) {
1079            Ok(paths) => paths,
1080            Err(e) => {
1081                return (
1082                    StatusCode::INTERNAL_SERVER_ERROR,
1083                    Json(ApiResponse::<ResubmitResponse>::error(e)),
1084                );
1085            }
1086        };
1087
1088    let config = match get_execution_config(module_id, assignment_id) {
1089        Ok(config) => config,
1090        Err(e) => {
1091            return (
1092                StatusCode::INTERNAL_SERVER_ERROR,
1093                Json(ApiResponse::<ResubmitResponse>::error(e)),
1094            );
1095        }
1096    };
1097
1098    let (resubmitted, failed) =
1099        execute_bulk_operation(submission_ids.clone(), assignment_id, db, |submission| {
1100            let db = db.clone();
1101            let assignment = assignment.clone();
1102            let base_path = base_path.clone();
1103            let memo_outputs = memo_outputs.clone();
1104            let mark_allocator_path = mark_allocator_path.clone();
1105            let config = config.clone();
1106            async move {
1107                if let Err(e) = clear_submission_output(&submission, &base_path) {
1108                    return Err(e);
1109                }
1110                if let Err(e) = process_submission_code(
1111                    &db,
1112                    submission.id,
1113                    config.clone(),
1114                    module_id,
1115                    assignment_id,
1116                )
1117                .await
1118                {
1119                    return Err(format!("Failed to run code for submission: {}", e));
1120                }
1121
1122                grade_submission(
1123                    submission,
1124                    &assignment,
1125                    &base_path,
1126                    &memo_outputs,
1127                    &mark_allocator_path,
1128                    &config,
1129                    &db,
1130                )
1131                .await
1132                .map(|_| ())
1133            }
1134        })
1135        .await;
1136
1137    let response = ResubmitResponse {
1138        resubmitted,
1139        failed,
1140    };
1141    let message = format!(
1142        "Resubmitted {}/{} submissions",
1143        resubmitted,
1144        submission_ids.len()
1145    );
1146
1147    (
1148        StatusCode::OK,
1149        Json(ApiResponse::success(response, &message)),
1150    )
1151}