Skip to main content

headless_lms_models/
teacher_grading_decisions.rs

1use std::collections::HashMap;
2
3use crate::prelude::*;
4use utoipa::ToSchema;
5
6#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
7
8pub struct TeacherGradingDecision {
9    pub id: Uuid,
10    pub user_exercise_state_id: Uuid,
11    pub user_id: Option<Uuid>,
12    pub created_at: DateTime<Utc>,
13    pub updated_at: DateTime<Utc>,
14    pub deleted_at: Option<DateTime<Utc>>,
15    pub score_given: f32,
16    pub teacher_decision: TeacherDecisionType,
17    pub justification: Option<String>,
18    pub hidden: Option<bool>,
19}
20
21#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type, ToSchema)]
22#[sqlx(type_name = "teacher_decision_type", rename_all = "kebab-case")]
23pub enum TeacherDecisionType {
24    FullPoints,
25    ZeroPoints,
26    CustomPoints,
27    SuspectedPlagiarism,
28    RejectAndReset,
29    UnauthorizedAiUse,
30    BadAnswer,
31    Other,
32}
33
34#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
35
36pub struct NewTeacherGradingDecision {
37    pub user_exercise_state_id: Uuid,
38    pub exercise_id: Uuid,
39    pub action: TeacherDecisionType,
40    pub manual_points: Option<f32>,
41    pub justification: Option<String>,
42    pub hidden: bool,
43    /// Resets the student's progress on the exercise so they can answer it again. Independent of
44    /// `action`, so a decision can record why the answer was rejected and still reopen it.
45    /// Requires the exercise to belong to a course.
46    pub reset_exercise: bool,
47}
48
49pub async fn add_teacher_grading_decision(
50    conn: &mut PgConnection,
51    user_exercise_state_id: Uuid,
52    action: TeacherDecisionType,
53    score_given: f32,
54    decision_maker_user_id: Option<Uuid>,
55    justification: Option<String>,
56    hidden: bool,
57) -> ModelResult<TeacherGradingDecision> {
58    let res = sqlx::query_as!(
59        TeacherGradingDecision,
60        r#"
61INSERT INTO teacher_grading_decisions (
62    user_exercise_state_id,
63    teacher_decision,
64    score_given,
65    user_id,
66    justification,
67    hidden
68  )
69VALUES ($1, $2, $3, $4, $5, $6)
70RETURNING *;
71      "#,
72        user_exercise_state_id,
73        action as TeacherDecisionType,
74        score_given,
75        decision_maker_user_id,
76        justification,
77        hidden
78    )
79    .fetch_one(conn)
80    .await?;
81    Ok(res)
82}
83
84#[allow(clippy::too_many_arguments)]
85pub async fn upsert_by_state_id_and_exercise_id(
86    conn: &mut PgConnection,
87    user_exercise_state_id: Uuid,
88    exercise_id: Uuid,
89    action: TeacherDecisionType,
90    score_given: f32,
91    decision_maker_user_id: Option<Uuid>,
92    justification: Option<String>,
93    hidden: bool,
94) -> ModelResult<TeacherGradingDecision> {
95    let res = sqlx::query_as!(
96        TeacherGradingDecision,
97        r#"
98INSERT INTO teacher_grading_decisions (
99    user_exercise_state_id,
100    teacher_decision,
101    score_given,
102    user_id,
103    justification,
104    hidden
105)
106SELECT ues.id, $3, $4, $5, $6, $7
107FROM user_exercise_states ues
108WHERE ues.id = $1
109  AND ues.exercise_id = $2
110  AND ues.deleted_at IS NULL
111RETURNING id,
112  user_exercise_state_id,
113  user_id,
114  created_at,
115  updated_at,
116  deleted_at,
117  score_given,
118  teacher_decision,
119  justification,
120  hidden;
121      "#,
122        user_exercise_state_id,
123        exercise_id,
124        action as TeacherDecisionType,
125        score_given,
126        decision_maker_user_id,
127        justification,
128        hidden
129    )
130    .fetch_one(conn)
131    .await?;
132    Ok(res)
133}
134
135pub async fn try_to_get_latest_grading_decision_by_user_exercise_state_id(
136    conn: &mut PgConnection,
137    user_exercise_state_id: Uuid,
138) -> ModelResult<Option<TeacherGradingDecision>> {
139    let res = sqlx::query_as!(
140        TeacherGradingDecision,
141        r#"
142SELECT *
143FROM teacher_grading_decisions
144WHERE user_exercise_state_id = $1
145  AND deleted_at IS NULL
146ORDER BY created_at DESC
147LIMIT 1
148      "#,
149        user_exercise_state_id,
150    )
151    .fetch_optional(conn)
152    .await?;
153    Ok(res)
154}
155
156/// The student's latest grading decision that still describes where they stand.
157///
158/// Unlike [`try_to_get_latest_grading_decision_by_user_exercise_state_id`], this looks the decision
159/// up by student and exercise, so it survives a reset replacing the user_exercise_state it was made
160/// against. A decision stops applying once the student answers again, or once anyone resets the
161/// exercise after it -- a later reset means the decision no longer describes the student's
162/// situation, while a reset made just before it is the one the decision itself ordered.
163pub async fn try_to_get_latest_grading_decision_still_addressed_to_student(
164    conn: &mut PgConnection,
165    user_id: Uuid,
166    exercise_id: Uuid,
167) -> ModelResult<Option<TeacherGradingDecision>> {
168    let res = sqlx::query_as!(
169        TeacherGradingDecision,
170        r#"
171SELECT tgd.id,
172  tgd.user_exercise_state_id,
173  tgd.user_id,
174  tgd.created_at,
175  tgd.updated_at,
176  tgd.deleted_at,
177  tgd.score_given,
178  tgd.teacher_decision AS "teacher_decision: _",
179  tgd.justification,
180  tgd.hidden
181FROM teacher_grading_decisions tgd
182  JOIN user_exercise_states ues ON ues.id = tgd.user_exercise_state_id
183WHERE ues.user_id = $1
184  AND ues.exercise_id = $2
185  AND tgd.deleted_at IS NULL
186  AND NOT EXISTS (
187    SELECT 1
188    FROM exercise_slide_submissions ess
189    WHERE ess.user_id = $1
190      AND ess.exercise_id = $2
191      AND ess.deleted_at IS NULL
192      AND ess.created_at > tgd.created_at
193  )
194  AND NOT EXISTS (
195    SELECT 1
196    FROM exercise_reset_logs erl
197    WHERE erl.reset_for = $1
198      AND erl.exercise_id = $2
199      AND erl.deleted_at IS NULL
200      AND erl.reset_at > tgd.created_at
201  )
202ORDER BY tgd.created_at DESC
203LIMIT 1
204      "#,
205        user_id,
206        exercise_id,
207    )
208    .fetch_optional(conn)
209    .await?;
210    Ok(res)
211}
212
213pub async fn try_to_get_latest_grading_decision_by_user_exercise_state_id_for_users(
214    conn: &mut PgConnection,
215    user_exercise_state_ids: &[Uuid],
216) -> ModelResult<HashMap<Uuid, TeacherGradingDecision>> {
217    let decisions = sqlx::query_as!(
218        TeacherGradingDecision,
219        r#"
220SELECT DISTINCT ON (user_exercise_state_id) id,
221  user_exercise_state_id,
222  user_id,
223  created_at,
224  updated_at,
225  deleted_at,
226  score_given,
227  teacher_decision,
228  justification,
229  hidden
230FROM teacher_grading_decisions
231WHERE user_exercise_state_id = ANY($1)
232  AND deleted_at IS NULL
233ORDER BY user_exercise_state_id,
234  created_at DESC
235      "#,
236        user_exercise_state_ids,
237    )
238    .fetch_all(conn)
239    .await?;
240
241    let mut res: HashMap<Uuid, TeacherGradingDecision> = HashMap::new();
242    for item in decisions.into_iter() {
243        res.insert(item.user_exercise_state_id, item);
244    }
245
246    Ok(res)
247}
248
249pub async fn get_all_latest_grading_decisions_by_user_id_and_course_id(
250    conn: &mut PgConnection,
251    user_id: Uuid,
252    course_id: Uuid,
253) -> ModelResult<Vec<TeacherGradingDecision>> {
254    let res = sqlx::query_as!(
255        TeacherGradingDecision,
256        r#"
257SELECT DISTINCT ON (user_exercise_state_id)
258  id,
259  user_exercise_state_id,
260  user_id,
261  created_at,
262  updated_at,
263  deleted_at,
264  score_given,
265  teacher_decision,
266  justification,
267  hidden
268FROM teacher_grading_decisions
269WHERE user_exercise_state_id IN (
270    SELECT user_exercise_states.id
271    FROM user_exercise_states
272    WHERE user_exercise_states.user_id = $1
273      AND user_exercise_states.course_id = $2
274      AND user_exercise_states.deleted_at IS NULL
275  )
276  AND deleted_at IS NULL
277  ORDER BY user_exercise_state_id, created_at DESC
278      "#,
279        user_id,
280        course_id,
281    )
282    .fetch_all(conn)
283    .await?;
284    Ok(res)
285}
286
287pub async fn get_all_latest_grading_decisions_by_user_id_and_exam_id(
288    conn: &mut PgConnection,
289    user_id: Uuid,
290    exam_id: Uuid,
291) -> ModelResult<Vec<TeacherGradingDecision>> {
292    let res = sqlx::query_as!(
293        TeacherGradingDecision,
294        r#"
295SELECT DISTINCT ON (user_exercise_state_id)
296  id,
297  user_exercise_state_id,
298  user_id,
299  created_at,
300  updated_at,
301  deleted_at,
302  score_given,
303  teacher_decision,
304  justification,
305  hidden
306FROM teacher_grading_decisions
307WHERE user_exercise_state_id IN (
308    SELECT user_exercise_states.id
309    FROM user_exercise_states
310    WHERE user_exercise_states.user_id = $1
311      AND user_exercise_states.exam_id = $2
312      AND user_exercise_states.deleted_at IS NULL
313  )
314  AND deleted_at IS NULL
315  ORDER BY user_exercise_state_id, created_at DESC
316      "#,
317        user_id,
318        exam_id,
319    )
320    .fetch_all(conn)
321    .await?;
322    Ok(res)
323}
324
325pub async fn update_teacher_grading_decision_hidden_field(
326    conn: &mut PgConnection,
327    teacher_grading_decision_id: Uuid,
328    hidden: bool,
329) -> ModelResult<TeacherGradingDecision> {
330    let res = sqlx::query_as!(
331        TeacherGradingDecision,
332        r#"
333        UPDATE teacher_grading_decisions
334        SET hidden = $1
335        WHERE id = $2
336        RETURNING *;
337              "#,
338        hidden,
339        teacher_grading_decision_id
340    )
341    .fetch_one(conn)
342    .await?;
343    Ok(res)
344}
345
346pub async fn get_by_ids(
347    conn: &mut PgConnection,
348    ids: &[Uuid],
349) -> ModelResult<Vec<TeacherGradingDecision>> {
350    let res = sqlx::query_as!(
351        TeacherGradingDecision,
352        r#"
353SELECT *
354FROM teacher_grading_decisions
355WHERE id = ANY($1)
356  AND deleted_at IS NULL
357        "#,
358        ids
359    )
360    .fetch_all(conn)
361    .await?;
362    Ok(res)
363}