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}