api/routes/modules/assignments/plagiarism/post.rs
1use crate::{response::ApiResponse, services::moss::MossService};
2use axum::{
3 Json,
4 extract::{Path, State},
5 http::StatusCode,
6 response::IntoResponse,
7};
8use chrono::Utc;
9use db::models::{
10 assignment_file,
11 assignment_submission::{self, Entity as SubmissionEntity},
12 plagiarism_case,
13 user::Entity as UserEntity,
14};
15use moss_parser::{ParseOptions, parse_moss};
16use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
17use serde::{Deserialize, Serialize};
18use util::{
19 execution_config::{ExecutionConfig, execution_config::Language},
20 state::AppState,
21};
22
23#[derive(Serialize, Deserialize)]
24pub struct CreatePlagiarismCasePayload {
25 pub submission_id_1: i64,
26 pub submission_id_2: i64,
27 pub description: String,
28 pub similarity: f32,
29}
30
31#[derive(Serialize)]
32pub struct PlagiarismCaseResponse {
33 pub id: i64,
34 pub assignment_id: i64,
35 pub submission_id_1: i64,
36 pub submission_id_2: i64,
37 pub description: String,
38 pub status: String,
39 pub similarity: f32,
40 pub created_at: chrono::DateTime<chrono::Utc>,
41 pub updated_at: chrono::DateTime<chrono::Utc>,
42}
43/// POST /api/modules/{module_id}/assignments/{assignment_id}/plagiarism
44///
45/// Creates a new plagiarism case between two submissions in a specific assignment.
46/// Accessible only to lecturers and assistant lecturers assigned to the module.
47///
48/// # Path Parameters
49///
50/// - `module_id`: The ID of the parent module
51/// - `assignment_id`: The ID of the assignment containing the submissions
52///
53/// # Request Body
54///
55/// Requires a JSON payload with the following fields:
56/// - `submission_id_1` (number): First submission ID (must differ from second)
57/// - `submission_id_2` (number): Second submission ID (must differ from first)
58/// - `description` (string): Explanation of the plagiarism case
59/// - `similarity` (number, required): Float **percentage** in the range **0.0–100.0** (inclusive)
60///
61/// # Returns
62///
63/// - `201 Created` with the newly created plagiarism case on success
64/// - `400 BAD REQUEST` for invalid payload (same submissions, missing/invalid similarity, bad range, etc.)
65/// - `403 FORBIDDEN` if user lacks required permissions
66/// - `500 INTERNAL SERVER ERROR` for database errors or creation failures
67///
68/// # Example Request
69///
70/// ```json
71/// {
72/// "submission_id_1": 42,
73/// "submission_id_2": 51,
74/// "description": "Similarity in logic and structure between both files.",
75/// "similarity": 72.5
76/// }
77/// ```
78///
79/// # Example Response (201 Created)
80///
81/// ```json
82/// {
83/// "success": true,
84/// "message": "Plagiarism case created successfully",
85/// "data": {
86/// "id": 17,
87/// "assignment_id": 3,
88/// "submission_id_1": 42,
89/// "submission_id_2": 51,
90/// "description": "Similarity in logic and structure between both files.",
91/// "status": "review",
92/// "similarity": 72.5,
93/// "created_at": "2024-05-20T14:30:00Z",
94/// "updated_at": "2024-05-20T14:30:00Z"
95/// }
96/// }
97/// ```
98///
99/// # Example Error Responses
100///
101/// - `400 Bad Request` (same submission IDs)
102/// ```json
103/// { "success": false, "message": "Submissions cannot be the same" }
104/// ```
105///
106/// - `400 Bad Request` (submission not found)
107/// ```json
108/// { "success": false, "message": "One or both submissions do not exist or belong to a different assignment" }
109/// ```
110///
111/// - `400 Bad Request` (similarity out of range)
112/// ```json
113/// { "success": false, "message": "Similarity must be between 0.0 and 100.0" }
114/// ```
115///
116/// - `403 Forbidden`
117/// ```json
118/// { "success": false, "message": "Forbidden: Insufficient permissions" }
119/// ```
120///
121/// - `500 Internal Server Error`
122/// ```json
123/// { "success": false, "message": "Failed to create plagiarism case" }
124/// ```
125pub async fn create_plagiarism_case(
126 State(app_state): State<AppState>,
127 Path((_, assignment_id)): Path<(i64, i64)>,
128 Json(payload): Json<CreatePlagiarismCasePayload>,
129) -> impl IntoResponse {
130 if payload.submission_id_1 == payload.submission_id_2 {
131 return (
132 StatusCode::BAD_REQUEST,
133 Json(ApiResponse::<()>::error(
134 "Submissions cannot be the same".to_string(),
135 )),
136 )
137 .into_response();
138 }
139
140 // Validate the similarity range (strictly enforced, no clamping)
141 if !(0.0_f32..=100.0_f32).contains(&payload.similarity) || !payload.similarity.is_finite() {
142 return (
143 StatusCode::BAD_REQUEST,
144 Json(ApiResponse::<()>::error(
145 "Similarity must be between 0.0 and 100.0".to_string(),
146 )),
147 )
148 .into_response();
149 }
150
151 let submission1 = SubmissionEntity::find_by_id(payload.submission_id_1)
152 .filter(assignment_submission::Column::AssignmentId.eq(assignment_id))
153 .one(app_state.db())
154 .await
155 .unwrap_or(None);
156
157 let submission2 = SubmissionEntity::find_by_id(payload.submission_id_2)
158 .filter(assignment_submission::Column::AssignmentId.eq(assignment_id))
159 .one(app_state.db())
160 .await
161 .unwrap_or(None);
162
163 if submission1.is_none() || submission2.is_none() {
164 return (
165 StatusCode::BAD_REQUEST,
166 Json(ApiResponse::<()>::error(
167 "One or both submissions do not exist or belong to a different assignment"
168 .to_string(),
169 )),
170 )
171 .into_response();
172 }
173
174 let new_case = plagiarism_case::Model::create_case(
175 app_state.db(),
176 assignment_id,
177 payload.submission_id_1,
178 payload.submission_id_2,
179 &payload.description,
180 payload.similarity, // required f32
181 )
182 .await;
183
184 match new_case {
185 Ok(case) => (
186 StatusCode::CREATED,
187 Json(ApiResponse::success(
188 PlagiarismCaseResponse {
189 id: case.id,
190 assignment_id,
191 submission_id_1: case.submission_id_1,
192 submission_id_2: case.submission_id_2,
193 description: case.description,
194 status: case.status.to_string(),
195 similarity: case.similarity,
196 created_at: case.created_at,
197 updated_at: case.updated_at,
198 },
199 "Plagiarism case created successfully",
200 )),
201 )
202 .into_response(),
203 Err(_) => (
204 StatusCode::INTERNAL_SERVER_ERROR,
205 Json(ApiResponse::<()>::error(
206 "Failed to create plagiarism case".to_string(),
207 )),
208 )
209 .into_response(),
210 }
211}
212
213// somewhere in your types for this route
214#[derive(serde::Deserialize)]
215pub struct MossRequest {
216 pub language: String,
217}
218
219/// POST /api/modules/{module_id}/assignments/{assignment_id}/plagiarism/moss
220///
221/// Runs a MOSS check on the **latest submission for every student** on the assignment,
222/// then parses the report and **auto-creates plagiarism cases** for each matched pair.
223/// Each created case is inserted with:
224/// - `status = "review"`
225/// - `similarity` as a **float percentage (0.0–100.0)** taken from MOSS’ `total_percent`
226/// - a generated, human-readable `description`
227///
228/// Accessible only to lecturers and assistant lecturers assigned to the module.
229///
230/// # Path Parameters
231///
232/// - `module_id`: The ID of the parent module
233/// - `assignment_id`: The ID of the assignment containing the submissions
234///
235/// # Request Body
236///
237/// **None.** The programming language is read from the assignment configuration
238/// (`project.language`) persisted for this assignment.
239///
240/// # Returns
241///
242/// - `200 OK` on success with details about the MOSS run and case creation:
243/// ```json
244/// {
245/// "success": true,
246/// "message": "MOSS check completed successfully; cases created from report",
247/// "data": {
248/// "report_url": "http://moss.stanford.edu/results/123456789",
249/// "cases_created": 7,
250/// "cases_skipped": 2,
251/// "title": "moss results for ... (optional)"
252/// }
253/// }
254/// ```
255/// - `500 INTERNAL SERVER ERROR` for MOSS server errors, parsing failures,
256/// or other unexpected failures. The response body contains an error message.
257///
258/// # Notes
259/// - Language is taken from the saved assignment config (`project.language`).
260/// - Base (starter) files attached to the assignment are included in the comparison if present.
261/// - The selected submissions are the most recent (highest `attempt`) per user.
262/// - Case creation is **deduplicated** per pair of submissions (order-independent).
263/// - `similarity` is stored as an `f32` percent, clamped to **0.0–100.0**.
264/// - Newly created cases start in `"review"` status and can be managed via the plagiarism cases API/UI.
265pub async fn run_moss_check(
266 State(app_state): State<AppState>,
267 Path((module_id, assignment_id)): Path<(i64, i64)>,
268) -> impl IntoResponse {
269 // 0) Load assignment config to determine language
270 let cfg = match ExecutionConfig::get_execution_config(module_id, assignment_id) {
271 Ok(c) => c,
272 Err(_e) => ExecutionConfig::default_config(), // fallback to defaults
273 };
274
275 // Map your enum -> MOSS language string
276 let moss_language: &str = match cfg.project.language {
277 Language::Cpp => "c++",
278 Language::Java => "java",
279 };
280
281 // 1) Collect latest submissions
282 let submissions = assignment_submission::Model::get_latest_submissions_for_assignment(
283 app_state.db(),
284 assignment_id,
285 )
286 .await;
287
288 match submissions {
289 Ok(submissions) => {
290 let mut submission_files = Vec::new();
291 for submission in &submissions {
292 let user = UserEntity::find_by_id(submission.user_id)
293 .one(app_state.db())
294 .await
295 .map_err(|_| "Failed to fetch user")
296 .unwrap();
297
298 // Optional username helps attribution in MOSS rows.
299 let username = user.map(|u| u.username);
300 submission_files.push((submission.full_path(), username, Some(submission.id)));
301 }
302
303 let base_files =
304 match assignment_file::Model::get_base_files(app_state.db(), assignment_id).await {
305 Ok(files) => files.into_iter().map(|f| f.full_path()).collect::<Vec<_>>(),
306 Err(_) => vec![],
307 };
308
309 let moss_user_id =
310 std::env::var("MOSS_USER_ID").unwrap_or_else(|_| "YOUR_MOSS_USER_ID".to_string());
311 let moss_service = MossService::new(&moss_user_id);
312
313 // 🔁 Use language from config, not from request payload
314 match moss_service
315 .run(base_files, submission_files, moss_language)
316 .await
317 {
318 Ok(report_url) => {
319 // 1) Persist a tiny text file (unchanged)
320 let report_dir = assignment_submission::Model::storage_root()
321 .join(format!("module_{}", module_id))
322 .join(format!("assignment_{}", assignment_id));
323
324 if let Err(e) = std::fs::create_dir_all(&report_dir) {
325 return (
326 StatusCode::INTERNAL_SERVER_ERROR,
327 Json(ApiResponse::<()>::error(format!(
328 "Failed to create report directory: {}",
329 e
330 ))),
331 )
332 .into_response();
333 }
334
335 let report_path = report_dir.join("reports.txt");
336 let content = format!(
337 "Report URL: {}\nDate: {}",
338 report_url,
339 Utc::now().to_rfc3339()
340 );
341
342 if let Err(e) = std::fs::write(&report_path, content) {
343 return (
344 StatusCode::INTERNAL_SERVER_ERROR,
345 Json(ApiResponse::<()>::error(format!(
346 "Failed to write MOSS report: {}",
347 e
348 ))),
349 )
350 .into_response();
351 }
352
353 // 2) Parse the report and auto-create plagiarism cases (with similarity)
354 let parse_opts = ParseOptions {
355 min_lines: 0, // tweak as needed
356 include_matches: false, // we only need aggregate % per pair
357 };
358
359 let parsed = match parse_moss(&report_url, parse_opts).await {
360 Ok(out) => out,
361 Err(e) => {
362 return (
363 StatusCode::INTERNAL_SERVER_ERROR,
364 Json(ApiResponse::<()>::error(format!(
365 "MOSS report parse failed: {}",
366 e
367 ))),
368 )
369 .into_response();
370 }
371 };
372
373 use std::collections::HashSet;
374 let mut seen: HashSet<(i64, i64)> = HashSet::new();
375 let mut created_count = 0usize;
376 let mut skipped_count = 0usize;
377
378 for r in parsed.reports {
379 let (Some(sub_a), Some(sub_b)) = (r.submission_id_a, r.submission_id_b)
380 else {
381 skipped_count += 1;
382 continue;
383 };
384
385 // Normalize order: (min, max)
386 let (a, b, ua, ub) = if sub_a <= sub_b {
387 (sub_a, sub_b, r.user_a, r.user_b)
388 } else {
389 (sub_b, sub_a, r.user_b, r.user_a)
390 };
391
392 if !seen.insert((a, b)) {
393 continue;
394 }
395
396 let description = generate_description(
397 &ua,
398 &ub,
399 a,
400 b,
401 r.total_lines_matched,
402 r.total_percent,
403 );
404
405 // Convert Option<f64> -> f32 and clamp to 0..=100
406 let similarity: f32 =
407 r.total_percent.unwrap_or(0.0).max(0.0).min(100.0) as f32;
408
409 match plagiarism_case::Model::create_case(
410 app_state.db(),
411 assignment_id,
412 a,
413 b,
414 &description,
415 similarity,
416 )
417 .await
418 {
419 Ok(_) => created_count += 1,
420 Err(_db_err) => {
421 // Log if desired
422 skipped_count += 1;
423 }
424 }
425 }
426
427 (
428 StatusCode::OK,
429 Json(ApiResponse::success(
430 serde_json::json!({
431 "report_url": report_url,
432 "cases_created": created_count,
433 "cases_skipped": skipped_count,
434 "title": parsed.title,
435 }),
436 "MOSS check completed successfully; cases created from report",
437 )),
438 )
439 .into_response()
440 }
441 Err(e) => (
442 StatusCode::INTERNAL_SERVER_ERROR,
443 Json(ApiResponse::<()>::error(format!(
444 "Failed to run MOSS check: {}",
445 e
446 ))),
447 )
448 .into_response(),
449 }
450 }
451 Err(_) => (
452 StatusCode::INTERNAL_SERVER_ERROR,
453 Json(ApiResponse::<()>::error(
454 "Failed to retrieve submissions".to_string(),
455 )),
456 )
457 .into_response(),
458 }
459}
460
461fn generate_description(
462 user_a: &str,
463 user_b: &str,
464 sub_a: i64,
465 sub_b: i64,
466 total_lines: i64,
467 total_percent: Option<f64>,
468) -> String {
469 let percent = total_percent.unwrap_or(0.0);
470
471 let level = if percent >= 90.0 {
472 "extensive similarity, indicating a strong likelihood of shared or reused code"
473 } else if percent >= 70.0 {
474 "substantial overlap, suggesting possible reuse or collaboration"
475 } else if percent >= 50.0 {
476 "moderate similarity, which may indicate shared structural elements"
477 } else if percent >= 30.0 {
478 "notable similarity, which could reflect influence or common coding approaches"
479 } else {
480 "limited similarity, likely due to common coding patterns or libraries"
481 };
482
483 format!(
484 "Submissions `{}` ({}) and `{}` ({}) show {}, with {} lines matched and a similarity score of {:.1}%.",
485 sub_a, user_a, sub_b, user_b, level, total_lines, percent
486 )
487}