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}