api/routes/modules/assignments/plagiarism/put.rs
1use axum::{
2 extract::{Path, State},
3 http::StatusCode,
4 response::IntoResponse,
5 Json,
6};
7use chrono::Utc;
8use db::models::plagiarism_case::{Entity as PlagiarismEntity, Status, Column as PlagiarismColumn};
9use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, ActiveModelTrait, IntoActiveModel};
10use serde::{Deserialize, Serialize};
11use util::state::AppState;
12use crate::response::ApiResponse;
13
14#[derive(Serialize, Deserialize)]
15pub struct UpdatePlagiarismCasePayload {
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub description: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub status: Option<String>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub similarity: Option<f32>,
22}
23
24#[derive(Debug, Serialize)]
25pub struct PlagiarismCaseResponse {
26 id: i64,
27 assignment_id: i64,
28 submission_id_1: i64,
29 submission_id_2: i64,
30 description: String,
31 status: String,
32 similarity: f32,
33 created_at: chrono::DateTime<Utc>,
34 updated_at: chrono::DateTime<Utc>,
35}
36
37/// PUT /api/modules/{module_id}/assignments/{assignment_id}/plagiarism/{case_id}
38///
39/// Updates an existing plagiarism case's description, status, and/or similarity percentage.
40/// Accessible only to lecturers and assistant lecturers assigned to the module.
41///
42/// # Path Parameters
43///
44/// - `module_id`: The ID of the parent module
45/// - `assignment_id`: The ID of the assignment containing the plagiarism case
46/// - `case_id`: The ID of the plagiarism case to update
47///
48/// # Request Body
49///
50/// Accepts a JSON payload with optional fields (at least one must be provided):
51/// - `description` (string): New description for the case
52/// - `status` (string): New status ("review", "flagged", or "reviewed")
53/// - `similarity` (number): New similarity percentage in **[0.0, 100.0]**
54///
55/// # Returns
56///
57/// - `200 OK` with the updated plagiarism case on success
58/// - `400 BAD REQUEST` for invalid parameters or missing update fields
59/// - `403 FORBIDDEN` if user lacks required permissions
60/// - `404 NOT FOUND` if the specified plagiarism case doesn't exist
61/// - `500 INTERNAL SERVER ERROR` for database errors or update failures
62///
63/// # Example Request
64///
65/// ```json
66/// {
67/// "description": "Lecturer has reviewed the case and added comments.",
68/// "status": "reviewed",
69/// "similarity": 68.25
70/// }
71/// ```
72///
73/// # Example Response (200 OK)
74///
75/// ```json
76/// {
77/// "success": true,
78/// "message": "Plagiarism case updated successfully",
79/// "data": {
80/// "id": 17,
81/// "assignment_id": 3,
82/// "submission_id_1": 42,
83/// "submission_id_2": 51,
84/// "description": "Lecturer has reviewed the case and added comments.",
85/// "status": "reviewed",
86/// "similarity": 68.25,
87/// "created_at": "2024-05-20T14:30:00Z",
88/// "updated_at": "2024-05-20T15:45:00Z"
89/// }
90/// }
91/// ```
92///
93/// # Example Responses
94///
95/// - `400 Bad Request` (missing update fields)
96/// ```json
97/// {
98/// "success": false,
99/// "message": "At least one field (description, status, or similarity) must be provided"
100/// }
101/// ```
102///
103/// - `400 Bad Request` (invalid status)
104/// ```json
105/// {
106/// "success": false,
107/// "message": "Invalid status value. Must be one of: 'review', 'flagged', 'reviewed'"
108/// }
109/// ```
110///
111/// - `400 Bad Request` (invalid similarity)
112/// ```json
113/// {
114/// "success": false,
115/// "message": "Invalid similarity: must be between 0 and 100"
116/// }
117/// ```
118///
119/// - `404 Not Found`
120/// ```json
121/// {
122/// "success": false,
123/// "message": "Plagiarism case not found"
124/// }
125/// ```
126///
127/// - `500 Internal Server Error`
128/// ```json
129/// {
130/// "success": false,
131/// "message": "Failed to update plagiarism case"
132/// }
133/// ```
134pub async fn update_plagiarism_case(
135 State(app_state): State<AppState>,
136 Path((_, assignment_id, case_id)): Path<(i64, i64, i64)>,
137 Json(payload): Json<UpdatePlagiarismCasePayload>,
138) -> impl IntoResponse {
139 // Require at least one updatable field
140 if payload.description.is_none() && payload.status.is_none() && payload.similarity.is_none() {
141 return (
142 StatusCode::BAD_REQUEST,
143 Json(ApiResponse::<PlagiarismCaseResponse>::error(
144 "At least one field (description, status, or similarity) must be provided",
145 )),
146 );
147 }
148
149 // Validate similarity, if provided
150 if let Some(sim) = payload.similarity {
151 if !(0.0..=100.0).contains(&sim) {
152 return (
153 StatusCode::BAD_REQUEST,
154 Json(ApiResponse::<PlagiarismCaseResponse>::error(
155 "Invalid similarity: must be between 0 and 100",
156 )),
157 );
158 }
159 }
160
161 // Fetch the case
162 let case = match PlagiarismEntity::find_by_id(case_id)
163 .filter(PlagiarismColumn::AssignmentId.eq(assignment_id))
164 .one(app_state.db())
165 .await
166 {
167 Ok(Some(case)) => case,
168 Ok(None) => {
169 return (
170 StatusCode::NOT_FOUND,
171 Json(ApiResponse::<PlagiarismCaseResponse>::error(
172 "Plagiarism case not found",
173 )),
174 );
175 }
176 Err(e) => {
177 return (
178 StatusCode::INTERNAL_SERVER_ERROR,
179 Json(ApiResponse::<PlagiarismCaseResponse>::error(format!(
180 "Database error: {}",
181 e
182 ))),
183 );
184 }
185 };
186
187 // Apply updates
188 let mut case = case.into_active_model();
189
190 if let Some(description) = payload.description {
191 case.description = sea_orm::ActiveValue::Set(description);
192 }
193
194 if let Some(status_str) = payload.status {
195 let status = match status_str.as_str() {
196 "review" => Status::Review,
197 "flagged" => Status::Flagged,
198 "reviewed" => Status::Reviewed,
199 _ => {
200 return (
201 StatusCode::BAD_REQUEST,
202 Json(ApiResponse::<PlagiarismCaseResponse>::error(
203 "Invalid status value. Must be one of: 'review', 'flagged', 'reviewed'",
204 )),
205 );
206 }
207 };
208 case.status = sea_orm::ActiveValue::Set(status);
209 }
210
211 if let Some(sim) = payload.similarity {
212 case.similarity = sea_orm::ActiveValue::Set(sim);
213 }
214
215 case.updated_at = sea_orm::ActiveValue::Set(Utc::now());
216
217 // Persist
218 let updated_case = match case.update(app_state.db()).await {
219 Ok(updated) => updated,
220 Err(e) => {
221 return (
222 StatusCode::INTERNAL_SERVER_ERROR,
223 Json(ApiResponse::<PlagiarismCaseResponse>::error(format!(
224 "Failed to update plagiarism case: {}",
225 e
226 ))),
227 );
228 }
229 };
230
231 // Respond
232 let response = PlagiarismCaseResponse {
233 id: updated_case.id,
234 assignment_id: updated_case.assignment_id,
235 submission_id_1: updated_case.submission_id_1,
236 submission_id_2: updated_case.submission_id_2,
237 description: updated_case.description,
238 status: updated_case.status.to_string(),
239 similarity: updated_case.similarity, // <- new
240 created_at: updated_case.created_at,
241 updated_at: updated_case.updated_at,
242 };
243
244 (
245 StatusCode::OK,
246 Json(ApiResponse::success(
247 response,
248 "Plagiarism case updated successfully",
249 )),
250 )
251}