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}