api/routes/modules/assignments/tasks/
get.rs

1//! Task Details Endpoint
2//!
3//! This module provides the endpoint handler for retrieving detailed information about a specific assignment task within a module, including its subsections and associated memo outputs. It interacts with the database to validate module, assignment, and task existence, loads the mark allocator configuration, and parses memo output files to provide detailed feedback for each subsection of the task.
4
5use crate::response::ApiResponse;
6use crate::routes::modules::assignments::tasks::common::TaskResponse;
7use axum::{
8    Json,
9    extract::{Path, State},
10    http::StatusCode,
11    response::IntoResponse,
12};
13use db::models::assignment_task::{Column, Entity};
14use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
15use serde::Serialize;
16use serde_json::Value;
17use std::{env, fs, path::PathBuf};
18use util::{execution_config::ExecutionConfig, state::AppState};
19use util::mark_allocator::mark_allocator::load_allocator;
20
21/// Represents the details of a subsection within a task, including its name, mark value, and optional memo output.
22#[derive(Serialize)]
23pub struct SubsectionDetail {
24    /// The name of the subsection.
25    pub name: String,
26    /// The value value assigned to this subsection.
27    pub value: i64,
28    /// The memo output content for this subsection, if available.
29    pub memo_output: Option<String>,
30}
31
32/// The response structure for detailed information about a task, including its subsections.
33#[derive(Serialize)]
34pub struct TaskDetailResponse {
35    /// The unique database ID of the task.
36    pub id: i64,
37    /// The task's ID (may be the same as `id`).
38    pub task_id: i64,
39    /// The display name assigned to a task
40    pub name: String,
41    /// The command associated with this task.
42    pub command: String,
43    /// The creation timestamp of the task (RFC3339 format).
44    pub created_at: String,
45    /// The last update timestamp of the task (RFC3339 format).
46    pub updated_at: String,
47    /// The list of subsections for this task, with details and memo outputs.
48    pub subsections: Vec<SubsectionDetail>,
49}
50
51/// GET /api/modules/{module_id}/assignments/{assignment_id}/tasks/{task_id}
52///
53/// Retrieve detailed information about a specific task within an assignment. Only accessible by lecturers or admins assigned to the module.
54///
55/// ### Path Parameters
56/// - `module_id` (i64): The ID of the module containing the assignment
57/// - `assignment_id` (i64): The ID of the assignment containing the task
58/// - `task_id` (i64): The ID of the task to retrieve details for
59///
60/// ### Responses
61///
62/// - `200 OK`
63/// ```json
64/// {
65///   "success": true,
66///   "message": "Task details retrieved successfully",
67///   "data": {
68///     "id": 123,
69///     "task_id": 123,
70///     "command": "java -cp . Main",
71///     "created_at": "2024-01-01T00:00:00Z",
72///     "updated_at": "2024-01-01T00:00:00Z",
73///     "subsections": [
74///       {
75///         "name": "Compilation",
76///         "value": 10,
77///         "memo_output": "Code compiles successfully without errors."
78///       },
79///       {
80///         "name": "Output",
81///         "value": 15,
82///         "memo_output": "Program produces correct output for all test cases."
83///       }
84///     ]
85///   }
86/// }
87/// ```
88///
89/// - `404 Not Found`
90/// ```json
91/// {
92///   "success": false,
93///   "message": "Module not found" // or "Assignment not found" or "Task not found" or "Assignment does not belong to this module" or "Task does not belong to this assignment" or "Task not found in allocator"
94/// }
95/// ```
96///
97/// - `500 Internal Server Error`
98/// ```json
99/// {
100///   "success": false,
101///   "message": "Database error retrieving module" // or "Database error retrieving assignment" or "Database error retrieving task" or "Failed to load mark allocator"
102/// }
103/// ```
104///
105pub async fn get_task_details(
106    State(app_state): State<AppState>,
107    Path((module_id, assignment_id, task_id)): Path<(i64, i64, i64)>,
108) -> impl IntoResponse {
109    let db = app_state.db();
110
111    let task = Entity::find_by_id(task_id)
112        .one(db)
113        .await
114        .unwrap()
115        .unwrap();
116
117    let base_path =
118        env::var("ASSIGNMENT_STORAGE_ROOT").unwrap_or_else(|_| "data/assignment_files".into());
119    let memo_path = PathBuf::from(&base_path)
120        .join(format!("module_{}", module_id))
121        .join(format!("assignment_{}", assignment_id))
122        .join("memo_output")
123        .join(format!("task_{}.txt", task.task_number));
124
125    let memo_content = fs::read_to_string(&memo_path).ok().or_else(|| {
126        fs::read_dir(memo_path.parent()?)
127            .ok()?
128            .filter_map(Result::ok)
129            .find_map(|entry| {
130                if entry.path().extension().map_or(false, |ext| ext == "txt") {
131                    fs::read_to_string(entry.path()).ok()
132                } else {
133                    None
134                }
135            })
136    });
137
138    // Load the ExecutionConfig to get the custom delimiter
139    let separator = match ExecutionConfig::get_execution_config(module_id, assignment_id) {
140        Ok(config) => config.marking.deliminator,
141        Err(_) => "&-=-&".to_string(), // fallback if config file missing or unreadable
142    };
143
144    // --- keep memo parsing logic unchanged ---
145    let outputs: Vec<Option<String>> = if let Some(ref memo) = memo_content {
146        let parts: Vec<&str> = memo.split(&separator).collect();
147        parts
148            .into_iter()
149            .map(|part| {
150                let trimmed = part.trim();
151                if trimmed.is_empty() {
152                    None
153                } else {
154                    Some(trimmed.to_string())
155                }
156            })
157            .collect()
158    } else {
159        vec![]
160    };
161
162    let parsed_outputs: Vec<Option<String>> = outputs.into_iter().skip(1).collect();
163
164    // --- UPDATED: read allocator in new keyed shape (with fallback to legacy flat shape) ---
165    // New shape example:
166    // {
167    //   "tasks": [
168    //     { "task1": { "name": "...", "task_number": 1, "value": 9, "subsections": [ ... ] } },
169    //     { "task2": { ... } }
170    //   ],
171    //   "total_value": 27
172    // }
173    //
174    // Legacy fallback (still supported):
175    // {
176    //   "tasks": [
177    //     { "task_number": 1, "value": 9, "subsections": [ ... ] },
178    //     ...
179    //   ],
180    //   "total_value": 27
181    // }
182    let allocator_json: Option<Value> = load_allocator(module_id, assignment_id).await.ok();
183
184    let ( _task_value, subsections_arr ): (i64, Vec<Value>) = if let Some(tasks_arr) = allocator_json
185        .as_ref()
186        .and_then(|v| v.get("tasks"))
187        .and_then(|t| t.as_array())
188    {
189        // Try new keyed shape first
190        let desired_key = format!("task{}", task.task_number);
191        let mut found: Option<(i64, Vec<Value>)> = None;
192
193        for entry in tasks_arr {
194            if let Some(entry_obj) = entry.as_object() {
195                if let Some(inner) = entry_obj.get(&desired_key) {
196                    if let Some(inner_obj) = inner.as_object() {
197                        let task_value = inner_obj
198                            .get("value")
199                            .and_then(|v| v.as_i64())
200                            .unwrap_or(0);
201                        let subsections = inner_obj
202                            .get("subsections")
203                            .and_then(|v| v.as_array())
204                            .cloned()
205                            .unwrap_or_default();
206                        found = Some((task_value, subsections));
207                        break;
208                    }
209                }
210            }
211        }
212
213        // If not found in keyed shape, fall back to legacy flat shape
214        if let Some(hit) = found {
215            hit
216        } else {
217            tasks_arr
218                .iter()
219                .find_map(|entry| {
220                    let obj = entry.as_object()?;
221                    let tn = obj.get("task_number")?.as_i64()?;
222                    if tn == task.task_number as i64 {
223                        let val = obj.get("value").and_then(|v| v.as_i64()).unwrap_or(0);
224                        let subs = obj
225                            .get("subsections")
226                            .and_then(|v| v.as_array())
227                            .cloned()
228                            .unwrap_or_default();
229                        Some((val, subs))
230                    } else {
231                        None
232                    }
233                })
234                .unwrap_or((0, vec![]))
235        }
236    } else {
237        (0, vec![])
238    };
239
240    // Align memo outputs vector length with number of subsections
241    let mut subsection_outputs = parsed_outputs;
242    subsection_outputs.resize(subsections_arr.len(), None);
243
244    // Build response subsections from subsections (name + value) and attach memo_output
245    let detailed_subsections: Vec<SubsectionDetail> = subsections_arr
246        .iter()
247        .enumerate()
248        .filter_map(|(i, c)| {
249            let obj = match c.as_object() {
250                Some(o) => o,
251                None => {
252                    eprintln!("Subsection {} is not a JSON object: {:?}", i, c);
253                    return None;
254                }
255            };
256
257            let name = obj
258                .get("name")
259                .and_then(|n| n.as_str())
260                .unwrap_or("<unnamed>")
261                .to_string();
262
263            let value = obj
264                .get("value")
265                .and_then(|v| v.as_i64())
266                .unwrap_or_else(|| {
267                    eprintln!("Missing or invalid value for subsection '{}'", name);
268                    0
269                });
270
271            let memo_output = subsection_outputs.get(i).cloned().flatten();
272
273            Some(SubsectionDetail {
274                name,
275                value,
276                memo_output,
277            })
278        })
279        .collect();
280
281    let resp = TaskDetailResponse {
282        id: task.id,
283        task_id: task.id,
284        name: task.name.clone(),
285        command: task.command,
286        created_at: task.created_at.to_rfc3339(),
287        updated_at: task.updated_at.to_rfc3339(),
288        subsections: detailed_subsections,
289    };
290
291    (
292        StatusCode::OK,
293        Json(ApiResponse::success(
294            resp,
295            "Task details retrieved successfully",
296        )),
297    )
298        .into_response()
299}
300
301/// GET /api/modules/{module_id}/assignments/{assignment_id}/tasks
302///
303/// Retrieve all tasks for a specific assignment. Only accessible by lecturers or admins assigned to the module.
304///
305/// ### Path Parameters
306/// - `module_id` (i64): The ID of the module containing the assignment
307/// - `assignment_id` (i64): The ID of the assignment to list tasks for
308///
309/// ### Responses
310///
311/// - `200 OK`
312/// ```json
313/// {
314///   "success": true,
315///   "message": "Tasks retrieved successfully",
316///   "data": [
317///     {
318///       "id": 123,
319///       "task_number": 1,
320///       "command": "java -cp . Main",
321///       "created_at": "2024-01-01T00:00:00Z",
322///       "updated_at": "2024-01-01T00:00:00Z"
323///     },
324///     {
325///       "id": 124,
326///       "task_number": 2,
327///       "command": "python main.py",
328///       "created_at": "2024-01-01T00:00:00Z",
329///       "updated_at": "2024-01-01T00:00:00Z"
330///     }
331///   ]
332/// }
333/// ```
334///
335/// - `404 Not Found`
336/// ```json
337/// {
338///   "success": false,
339///   "message": "Assignment or module not found"
340/// }
341/// ```
342///
343/// - `500 Internal Server Error`
344/// ```json
345/// {
346///   "success": false,
347///   "message": "Database error" // or "Failed to retrieve tasks"
348/// }
349/// ```
350///
351pub async fn list_tasks(
352    State(app_state): State<AppState>,
353    Path((_, assignment_id)): Path<(i64, i64)>,
354) -> impl IntoResponse {
355    let db = app_state.db();
356
357    match Entity::find()
358        .filter(Column::AssignmentId.eq(assignment_id))
359        .order_by_asc(Column::TaskNumber)
360        .all(db)
361        .await
362    {
363        Ok(tasks) => {
364            let data = tasks
365                .into_iter()
366                .map(|task| TaskResponse {
367                    id: task.id,
368                    task_number: task.task_number,
369                    name: task.name,
370                    command: task.command,
371                    created_at: task.created_at.to_rfc3339(),
372                    updated_at: task.updated_at.to_rfc3339(),
373                })
374                .collect::<Vec<_>>();
375
376            (
377                StatusCode::OK,
378                Json(ApiResponse::success(data, "Tasks retrieved successfully")),
379            )
380                .into_response()
381        }
382        Err(_) => (
383            StatusCode::INTERNAL_SERVER_ERROR,
384            Json(ApiResponse::<Vec<TaskResponse>>::error(
385                "Failed to retrieve tasks",
386            )),
387        )
388            .into_response(),
389    }
390}