Skip to main content

headless_lms_server/controllers/main_frontend/
teacher_grading_decisions.rs

1use crate::prelude::*;
2use headless_lms_models::{
3    teacher_grading_decisions::{NewTeacherGradingDecision, TeacherDecisionType},
4    user_exercise_states::UserExerciseState,
5};
6use utoipa::OpenApi;
7
8#[derive(OpenApi)]
9#[openapi(paths(create_teacher_grading_decision))]
10pub(crate) struct MainFrontendTeacherGradingDecisionsApiDoc;
11
12/**
13POST `/api/v0/main-frontend/teacher-grading-decisions` - Creates a new teacher grading decision, overriding the points a user has received from an exercise.
14*/
15#[utoipa::path(
16    post,
17    path = "",
18    operation_id = "createTeacherGradingDecision",
19    tag = "teacher_grading_decisions",
20    request_body = NewTeacherGradingDecision,
21    responses(
22        (status = 200, description = "Teacher grading decision created", body = Option<UserExerciseState>)
23    )
24)]
25#[instrument(skip(pool))]
26async fn create_teacher_grading_decision(
27    payload: web::Json<NewTeacherGradingDecision>,
28    pool: web::Data<PgPool>,
29    user: AuthUser,
30) -> ControllerResult<web::Json<Option<UserExerciseState>>> {
31    let action = &payload.action;
32    let exercise_id = payload.exercise_id;
33    let user_exercise_state_id = payload.user_exercise_state_id;
34    let manual_points = payload.manual_points;
35    let justification = &payload.justification;
36    let hidden = payload.hidden;
37    let mut conn = pool.acquire().await?;
38
39    let student_state =
40        models::user_exercise_states::get_by_id(&mut conn, user_exercise_state_id).await?;
41    if student_state.exercise_id != exercise_id {
42        return Err(controller_err!(
43            Forbidden,
44            "User exercise state does not belong to the requested exercise".to_string()
45        ));
46    }
47    let exercise =
48        models::exercises::get_non_deleted_by_id(&mut conn, student_state.exercise_id).await?;
49    if exercise.course_id != student_state.course_id || exercise.exam_id != student_state.exam_id {
50        return Err(controller_err!(
51            Forbidden,
52            "User exercise state does not match the requested exercise context".to_string()
53        ));
54    }
55
56    let token = authorize(
57        &mut conn,
58        Act::Edit,
59        Some(user.id),
60        Res::Exercise(student_state.exercise_id),
61    )
62    .await?;
63    // A match rather than an if/else chain: a new decision type must be given points here
64    // deliberately instead of silently falling through to "Invalid query".
65    let points_given = match *action {
66        TeacherDecisionType::FullPoints => exercise.score_maximum as f32,
67        TeacherDecisionType::ZeroPoints
68        | TeacherDecisionType::SuspectedPlagiarism
69        | TeacherDecisionType::UnauthorizedAiUse
70        | TeacherDecisionType::RejectAndReset
71        | TeacherDecisionType::BadAnswer
72        | TeacherDecisionType::Other => 0.0,
73        TeacherDecisionType::CustomPoints => {
74            let points = manual_points.unwrap_or(0.0);
75            if points < 0.0 || points > exercise.score_maximum as f32 {
76                return Err(controller_err!(
77                    BadRequest,
78                    "manual_points must be between 0 and the exercise's maximum points".to_string()
79                ));
80            }
81            points
82        }
83    };
84
85    info!(
86        "Teacher took the following action: {:?}. Points given: {:?}.",
87        &action, points_given
88    );
89
90    // RejectAndReset is the older single-action spelling of the same request.
91    if payload.reset_exercise || *action == TeacherDecisionType::RejectAndReset {
92        let course_id = student_state.course_id.ok_or_else(|| {
93            ControllerError::new(
94                ControllerErrorType::BadRequest,
95                "Resetting the exercise requires it to belong to a course".to_string(),
96                None,
97            )
98        })?;
99
100        let _reset = models::exercises::reset_progress_by_course_id_user_ids_and_exercise_ids(
101            &mut conn,
102            course_id,
103            &[student_state.user_id],
104            &[student_state.exercise_id],
105            Some(user.id),
106            Some("reset-by-staff".to_string()),
107        )
108        .await?;
109
110        // Recorded after the reset, with the plain insert since the reset soft-deleted the state
111        // the upsert validates against. A decision predating its own reset reads as superseded.
112        let _res = models::teacher_grading_decisions::add_teacher_grading_decision(
113            &mut conn,
114            user_exercise_state_id,
115            *action,
116            points_given,
117            Some(user.id),
118            justification.clone(),
119            hidden,
120        )
121        .await?;
122
123        return token.authorized_ok(web::Json(None));
124    }
125
126    let _res = models::teacher_grading_decisions::upsert_by_state_id_and_exercise_id(
127        &mut conn,
128        user_exercise_state_id,
129        student_state.exercise_id,
130        *action,
131        points_given,
132        Some(user.id),
133        justification.clone(),
134        hidden,
135    )
136    .await?;
137
138    let new_user_exercise_state = models::user_exercise_states::recalculate_by_id_and_exercise_id(
139        &mut conn,
140        user_exercise_state_id,
141        student_state.exercise_id,
142    )
143    .await?;
144
145    if let Some(course_id) = new_user_exercise_state.course_id {
146        // Since the teacher just reviewed the submission we should mark possible peer review queue entries so that they won't be given to others to review. Receiving peer reviews for this answer now would not make much sense.
147        models::peer_review_queue_entries::remove_queue_entries_for_unusual_reason(
148            &mut conn,
149            new_user_exercise_state.user_id,
150            new_user_exercise_state.exercise_id,
151            course_id,
152        )
153        .await?;
154    }
155
156    token.authorized_ok(web::Json(Some(new_user_exercise_state)))
157}
158
159pub fn _add_routes(cfg: &mut ServiceConfig) {
160    cfg.route("", web::post().to(create_teacher_grading_decision));
161}