api/routes/modules/assignments/mark_allocator/
put.rs

1use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};
2use crate::response::ApiResponse;
3use serde_json::Value;
4use util::mark_allocator::mark_allocator::{save_allocator, SaveError};
5
6/// PUT /api/modules/{module_id}/assignments/{assignment_id}/mark_allocator
7///
8/// Save the mark allocator JSON configuration for a specific assignment. Accessible to users with
9/// Lecturer roles assigned to the module.
10///
11/// This endpoint saves a mark allocator configuration to the assignment directory. The configuration
12/// defines how many **points** (values) are allocated to each task and its subsections. The saved
13/// configuration is used by the grading system to calculate final marks.
14///
15/// ### Path Parameters
16/// - `module_id` (i64): The ID of the module containing the assignment
17/// - `assignment_id` (i64): The ID of the assignment to save the mark allocator for
18///
19/// ### Request Body (points-based schema using `value`)
20/// A JSON object with:
21/// - `tasks`: non-empty array of **single-key** task objects: `{ "taskN": { ... } }`
22/// - `total_value`: integer (≥ 0) equal to the sum of all task values
23///
24/// ```json
25/// {
26///   "generated_at": "2025-08-17T22:00:00Z",
27///   "tasks": [
28///     {
29///       "task1": {
30///         "name": "Task 1",
31///         "task_number": 1,
32///         "value": 9,
33///         "subsections": [
34///           { "name": "Correctness", "value": 5 },
35///           { "name": "Style",       "value": 4 }
36///         ]
37///       }
38///     },
39///     {
40///       "task2": {
41///         "name": "Task 2",
42///         "task_number": 2,
43///         "value": 6,
44///         "subsections": [
45///           { "name": "Docs", "value": 2 },
46///           { "name": "Tests","value": 4 }
47///         ]
48///       }
49///     }
50///   ],
51///   "total_value": 15
52/// }
53/// ```
54///
55/// #### Field semantics
56/// - `tasks` (required, non-empty array): Each element must be an object with **exactly one** key `"taskN"`.
57///   The value of that key is a task object with:
58///   - `name` (required, non-empty string)
59///   - `task_number` (required, positive integer). Must match the number N in `"taskN"`.
60///   - `value` (required, integer ≥ 0): Total points for the task
61///   - `subsections` (required, array; may be empty):
62///     - Each item: `{ "name": string (non-empty), "value": integer ≥ 0 }`
63///     - The sum of all subsection values must equal the task `value`
64/// - `total_value` (required, integer ≥ 0): Must equal the sum of all task values
65///
66/// ### Success Response (200 OK)
67/// ```json
68/// { "success": true, "message": "Mark allocator successfully saved.", "data": "{}" }
69/// ```
70///
71/// ### Error Responses
72/// - **400 Bad Request** – Invalid structure or values  
73/// - **404 Not Found** – Module or assignment directory does not exist  
74/// - **500 Internal Server Error** – Save failure
75pub async fn save(
76    Path((module_id, assignment_id)): Path<(i64, i64)>,
77    Json(req): Json<Value>,
78) -> impl IntoResponse {
79    let bad = |msg: &str| {
80        (
81            StatusCode::BAD_REQUEST,
82            Json(ApiResponse::<()>::error(msg)),
83        )
84            .into_response()
85    };
86
87    // Root must be an object with "tasks" array and "total_value" integer
88    let root = match req.as_object() {
89        Some(o) => o,
90        None => return bad("Body must be a JSON object"),
91    };
92
93    let tasks = match root.get("tasks").and_then(|t| t.as_array()) {
94        Some(a) if !a.is_empty() => a,
95        _ => return bad("\"tasks\" must be a non-empty array"),
96    };
97
98    let total_value = match root.get("total_value").and_then(|v| v.as_i64()) {
99        Some(v) if v >= 0 => v,
100        _ => return bad("\"total_value\" must be an integer >= 0"),
101    };
102
103    let mut sum_task_values: i64 = 0;
104
105    for (idx, entry) in tasks.iter().enumerate() {
106        // Each entry must be an object with exactly one key: "taskN"
107        let entry_obj = match entry.as_object() {
108            Some(m) if m.len() == 1 => m,
109            Some(_) => {
110                return bad(&format!(
111                    "tasks[{}] must be an object with exactly one key (e.g., \"task1\")",
112                    idx
113                ))
114            }
115            None => return bad(&format!("tasks[{}] must be an object", idx)),
116        };
117
118        let (task_key, task_val) = entry_obj.iter().next().unwrap();
119
120        // Validate key format: taskN, extract N
121        if !task_key.starts_with("task") {
122            return bad(&format!(
123                "tasks[{}] invalid key '{}': expected key like \"task1\"",
124                idx, task_key
125            ));
126        }
127        let key_num_part = &task_key[4..];
128        let key_task_num: i64 = match key_num_part.parse::<i64>() {
129            Ok(n) if n > 0 => n,
130            _ => {
131                return bad(&format!(
132                    "tasks[{}] invalid key '{}': expected positive integer after 'task'",
133                    idx, task_key
134                ))
135            }
136        };
137
138        // Inner task object
139        let task_obj = match task_val.as_object() {
140            Some(o) => o,
141            None => {
142                return bad(&format!(
143                    "tasks[{}].{} value must be an object",
144                    idx, task_key
145                ))
146            }
147        };
148
149        // name
150        let name = match task_obj.get("name").and_then(|v| v.as_str()) {
151            Some(s) if !s.trim().is_empty() => s,
152            _ => {
153                return bad(&format!(
154                    "tasks[{}].{}.name must be a non-empty string",
155                    idx, task_key
156                ))
157            }
158        };
159
160        // task_number (must match key)
161        let task_number = match task_obj.get("task_number").and_then(|v| v.as_i64()) {
162            Some(n) if n > 0 => n,
163            _ => {
164                return bad(&format!(
165                    "tasks[{}].{}.task_number must be a positive integer",
166                    idx, task_key
167                ))
168            }
169        };
170        if task_number != key_task_num {
171            return bad(&format!(
172                "tasks[{}]: key '{}' does not match inner task_number {}",
173                idx, task_key, task_number
174            ));
175        }
176
177        // task value
178        let task_value = match task_obj.get("value").and_then(|v| v.as_i64()) {
179            Some(v) if v >= 0 => v,
180            _ => {
181                return bad(&format!(
182                    "tasks[{}].{}.value must be an integer >= 0",
183                    idx, task_key
184                ))
185            }
186        };
187
188        // subsections (required array; may be empty)
189        let subsections = match task_obj.get("subsections").and_then(|v| v.as_array()) {
190            Some(a) => a,
191            None => {
192                return bad(&format!(
193                    "tasks[{}].{}.subsections must be an array (can be empty)",
194                    idx, task_key
195                ))
196            }
197        };
198
199        // Validate subsections and sum values
200        let mut sum_sub_values: i64 = 0;
201        for (sidx, s) in subsections.iter().enumerate() {
202            let s_obj = match s.as_object() {
203                Some(o) => o,
204                None => {
205                    return bad(&format!(
206                        "tasks[{}].{}.subsections[{}] must be an object",
207                        idx, task_key, sidx
208                    ))
209                }
210            };
211
212            match s_obj.get("name").and_then(|v| v.as_str()) {
213                Some(n) if !n.trim().is_empty() => {}
214                _ => {
215                    return bad(&format!(
216                        "tasks[{}].{}.subsections[{}].name must be a non-empty string",
217                        idx, task_key, sidx
218                    ))
219                }
220            }
221
222            let sub_value = match s_obj.get("value").and_then(|v| v.as_i64()) {
223                Some(v) if v >= 0 => v,
224                _ => {
225                    return bad(&format!(
226                        "tasks[{}].{}.subsections[{}].value must be an integer >= 0",
227                        idx, task_key, sidx
228                    ))
229                }
230            };
231
232            sum_sub_values += sub_value;
233        }
234
235        if sum_sub_values != task_value {
236            return bad(&format!(
237                "tasks[{}] ('{}' / {}): sum of subsection values ({}) must equal task value ({})",
238                idx, name, task_key, sum_sub_values, task_value
239            ));
240        }
241
242        sum_task_values += task_value;
243    }
244
245    if sum_task_values != total_value {
246        return bad(&format!(
247            "sum of task values must equal total_value ({}), got {}",
248            total_value, sum_task_values
249        ));
250    }
251
252    match save_allocator(module_id, assignment_id, req).await {
253        Ok(_) => (
254            StatusCode::OK,
255            Json(ApiResponse::success("{}", "Mark allocator successfully saved.")),
256        )
257            .into_response(),
258        Err(SaveError::DirectoryNotFound) => (
259            StatusCode::NOT_FOUND,
260            Json::<ApiResponse<()>>(ApiResponse::error(
261                "Module or assignment directory does not exist",
262            )),
263        )
264            .into_response(),
265        Err(SaveError::JsonError(_)) => (
266            StatusCode::BAD_REQUEST,
267            Json::<ApiResponse<()>>(ApiResponse::error("Invalid JSON")),
268        )
269            .into_response(),
270        Err(_) => (
271            StatusCode::INTERNAL_SERVER_ERROR,
272            Json::<ApiResponse<()>>(ApiResponse::error("Could not save file")),
273        )
274            .into_response(),
275    }
276}