headless_lms_models/
peer_review_queue_entries.rs

1use crate::{
2    exercises,
3    library::user_exercise_state_updater,
4    prelude::*,
5    teacher_grading_decisions,
6    user_exercise_states::{self, ReviewingStage},
7};
8
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Eq)]
10#[cfg_attr(feature = "ts_rs", derive(TS))]
11pub struct PeerReviewQueueEntry {
12    pub id: Uuid,
13    pub created_at: DateTime<Utc>,
14    pub updated_at: DateTime<Utc>,
15    pub deleted_at: Option<DateTime<Utc>>,
16    pub user_id: Uuid,
17    pub exercise_id: Uuid,
18    pub course_id: Uuid,
19    pub receiving_peer_reviews_exercise_slide_submission_id: Uuid,
20    pub received_enough_peer_reviews: bool,
21    pub peer_review_priority: i32,
22    pub removed_from_queue_for_unusual_reason: bool,
23}
24
25pub async fn insert(
26    conn: &mut PgConnection,
27    pkey_policy: PKeyPolicy<Uuid>,
28    user_id: Uuid,
29    exercise_id: Uuid,
30    course_id: Uuid,
31    receiving_peer_reviews_exercise_slide_submission_id: Uuid,
32    peer_review_priority: i32,
33) -> ModelResult<Uuid> {
34    let res = sqlx::query!(
35        "
36INSERT INTO peer_review_queue_entries (
37    id,
38    user_id,
39    exercise_id,
40    course_id,
41    receiving_peer_reviews_exercise_slide_submission_id,
42    peer_review_priority
43  )
44VALUES ($1, $2, $3, $4, $5, $6)
45RETURNING id
46        ",
47        pkey_policy.into_uuid(),
48        user_id,
49        exercise_id,
50        course_id,
51        receiving_peer_reviews_exercise_slide_submission_id,
52        peer_review_priority,
53    )
54    .fetch_one(conn)
55    .await?;
56    Ok(res.id)
57}
58
59/// Inserts or updates the queue entry indexed by `user_id`, `exercise_id` and `course_id`.
60///
61/// The value for `receiving_peer_reviews_exercise_slide_submission_id` never changes after the initial
62/// insertion. This is to make sure that all received peer reviews are made for the same exercise slide
63/// submission. The same applies to `received_enough_peer_reviews` to avoid the scenario where it might
64/// be set from `true` back to `false`.
65pub async fn upsert_peer_review_priority(
66    conn: &mut PgConnection,
67    user_id: Uuid,
68    exercise_id: Uuid,
69    course_id: Uuid,
70    peer_review_priority: i32,
71    receiving_peer_reviews_exercise_slide_submission_id: Uuid,
72    received_enough_peer_reviews: bool,
73) -> ModelResult<PeerReviewQueueEntry> {
74    let res = sqlx::query_as!(
75        PeerReviewQueueEntry,
76        "
77INSERT INTO peer_review_queue_entries (
78    user_id,
79    exercise_id,
80    course_id,
81    peer_review_priority,
82    receiving_peer_reviews_exercise_slide_submission_id,
83    received_enough_peer_reviews
84  )
85VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (user_id, exercise_id, course_id, deleted_at) DO
86UPDATE
87SET peer_review_priority = $4
88RETURNING *
89        ",
90        user_id,
91        exercise_id,
92        course_id,
93        peer_review_priority,
94        receiving_peer_reviews_exercise_slide_submission_id,
95        received_enough_peer_reviews,
96    )
97    .fetch_one(conn)
98    .await?;
99    Ok(res)
100}
101
102pub async fn update_received_enough_peer_reviews(
103    conn: &mut PgConnection,
104    id: Uuid,
105    received_enough_peer_reviews: bool,
106) -> ModelResult<PeerReviewQueueEntry> {
107    let res = sqlx::query_as!(
108        PeerReviewQueueEntry,
109        "
110UPDATE peer_review_queue_entries
111SET received_enough_peer_reviews = $1
112WHERE id = $2
113  AND deleted_at IS NULL
114RETURNING *;
115        ",
116        received_enough_peer_reviews,
117        id,
118    )
119    .fetch_one(conn)
120    .await?;
121    Ok(res)
122}
123
124pub async fn update(
125    conn: &mut PgConnection,
126    id: Uuid,
127    receiving_peer_reviews_exercise_slide_submission_id: Uuid,
128    peer_review_priority: i32,
129) -> ModelResult<PeerReviewQueueEntry> {
130    let res = sqlx::query_as!(
131        PeerReviewQueueEntry,
132        "
133UPDATE peer_review_queue_entries
134SET receiving_peer_reviews_exercise_slide_submission_id = $1,
135  peer_review_priority = $2
136WHERE id = $3
137RETURNING *
138    ",
139        receiving_peer_reviews_exercise_slide_submission_id,
140        peer_review_priority,
141        id
142    )
143    .fetch_one(conn)
144    .await?;
145    Ok(res)
146}
147
148pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<PeerReviewQueueEntry> {
149    let res = sqlx::query_as!(
150        PeerReviewQueueEntry,
151        "
152SELECT *
153FROM peer_review_queue_entries
154WHERE id = $1
155  AND deleted_at IS NULL
156        ",
157        id
158    )
159    .fetch_one(conn)
160    .await?;
161    Ok(res)
162}
163
164async fn get_by_receiving_peer_reviews_submission_and_course_ids(
165    conn: &mut PgConnection,
166    receiving_peer_reviews_exercise_slide_submission_id: Uuid,
167    course_id: Uuid,
168) -> ModelResult<PeerReviewQueueEntry> {
169    let res = sqlx::query_as!(
170        PeerReviewQueueEntry,
171        "
172SELECT *
173FROM peer_review_queue_entries
174WHERE receiving_peer_reviews_exercise_slide_submission_id = $1
175  AND course_id = $2
176  AND deleted_at IS NULL
177    ",
178        receiving_peer_reviews_exercise_slide_submission_id,
179        course_id
180    )
181    .fetch_one(conn)
182    .await?;
183    Ok(res)
184}
185
186pub async fn try_to_get_by_receiving_submission_and_course_ids(
187    conn: &mut PgConnection,
188    receiving_peer_reviews_exercise_slide_submission_id: Uuid,
189    course_id: Uuid,
190) -> ModelResult<Option<PeerReviewQueueEntry>> {
191    get_by_receiving_peer_reviews_submission_and_course_ids(
192        conn,
193        receiving_peer_reviews_exercise_slide_submission_id,
194        course_id,
195    )
196    .await
197    .optional()
198}
199
200pub async fn get_by_user_and_exercise_and_course_ids(
201    conn: &mut PgConnection,
202    user_id: Uuid,
203    exercise_id: Uuid,
204    course_id: Uuid,
205) -> ModelResult<PeerReviewQueueEntry> {
206    let res = sqlx::query_as!(
207        PeerReviewQueueEntry,
208        "
209SELECT *
210FROM peer_review_queue_entries
211WHERE user_id = $1
212  AND exercise_id = $2
213  AND course_id = $3
214  AND deleted_at IS NULL
215        ",
216        user_id,
217        exercise_id,
218        course_id,
219    )
220    .fetch_one(conn)
221    .await?;
222    Ok(res)
223}
224
225pub async fn try_to_get_by_user_and_exercise_and_course_ids(
226    conn: &mut PgConnection,
227    user_id: Uuid,
228    exercise_id: Uuid,
229    course_id: Uuid,
230) -> ModelResult<Option<PeerReviewQueueEntry>> {
231    get_by_user_and_exercise_and_course_ids(conn, user_id, exercise_id, course_id)
232        .await
233        .optional()
234}
235
236/// Gets multiple records of `PeerReviewQueueEntry` ordered by newest queue entries first. Also returns entries that don't need peer review.
237///
238/// Doesn't differentiate between different course instances.
239pub async fn get_any_including_not_needing_review(
240    conn: &mut PgConnection,
241    exercise_id: Uuid,
242    excluded_user_id: Uuid,
243    excluded_submissions_ids: &[Uuid],
244    count: i64,
245) -> ModelResult<Vec<PeerReviewQueueEntry>> {
246    let res = sqlx::query_as!(
247        PeerReviewQueueEntry,
248        "
249SELECT *
250FROM peer_review_queue_entries
251WHERE exercise_id = $1
252  AND user_id <> $2
253  AND receiving_peer_reviews_exercise_slide_submission_id <> ALL($3)
254  AND deleted_at IS NULL
255ORDER BY created_at DESC
256LIMIT $4
257            ",
258        exercise_id,
259        excluded_user_id,
260        excluded_submissions_ids,
261        count,
262    )
263    .fetch_all(conn)
264    .await?;
265    Ok(res)
266}
267
268/// Gets multiple records of `PeerReviewQueueEntry` that still require more peer reviews, ordered by
269/// peer review priority.
270///
271/// Doesn't differentiate between different course instances.
272pub async fn get_many_that_need_peer_reviews_by_exercise_id_and_review_priority(
273    conn: &mut PgConnection,
274    exercise_id: Uuid,
275    excluded_user_id: Uuid,
276    excluded_submissions_ids: &[Uuid],
277    count: i64,
278) -> ModelResult<Vec<PeerReviewQueueEntry>> {
279    let res = sqlx::query_as!(
280        PeerReviewQueueEntry,
281        "
282SELECT *
283FROM peer_review_queue_entries
284WHERE exercise_id = $1
285  AND user_id <> $2
286  AND receiving_peer_reviews_exercise_slide_submission_id <> ALL($3)
287  AND received_enough_peer_reviews = 'false'
288  AND removed_from_queue_for_unusual_reason = 'false'
289  AND deleted_at IS NULL
290ORDER BY peer_review_priority DESC
291LIMIT $4
292        ",
293        exercise_id,
294        excluded_user_id,
295        excluded_submissions_ids,
296        count,
297    )
298    .fetch_all(conn)
299    .await?;
300    Ok(res)
301}
302
303/// Gets multiple records of `PeerReviewQueueEntry` that still require more peer reviews, ordered by
304/// peer review priority.
305///
306/// Doesn't differentiate between different course instances.
307pub async fn get_all_that_need_peer_reviews_by_exercise_id(
308    conn: &mut PgConnection,
309    exercise_id: Uuid,
310) -> ModelResult<Vec<PeerReviewQueueEntry>> {
311    let res = sqlx::query_as!(
312        PeerReviewQueueEntry,
313        "
314SELECT *
315FROM peer_review_queue_entries
316WHERE exercise_id = $1
317  AND received_enough_peer_reviews = 'false'
318  AND deleted_at IS NULL
319        ",
320        exercise_id,
321    )
322    .fetch_all(conn)
323    .await?;
324    Ok(res)
325}
326
327pub async fn increment_peer_review_priority(
328    conn: &mut PgConnection,
329    peer_review_queue_entry: PeerReviewQueueEntry,
330) -> ModelResult<PeerReviewQueueEntry> {
331    let res = sqlx::query_as!(
332        PeerReviewQueueEntry,
333        "
334UPDATE peer_review_queue_entries
335SET peer_review_priority = $1
336WHERE id = $2
337  AND deleted_at IS NULL
338RETURNING *
339    ",
340        peer_review_queue_entry.peer_review_priority + 1,
341        peer_review_queue_entry.id
342    )
343    .fetch_one(conn)
344    .await?;
345    Ok(res)
346}
347
348pub async fn remove_queue_entries_for_unusual_reason(
349    conn: &mut PgConnection,
350    user_id: Uuid,
351    exercise_id: Uuid,
352    course_id: Uuid,
353) -> ModelResult<()> {
354    sqlx::query!(
355        "
356UPDATE peer_review_queue_entries
357SET removed_from_queue_for_unusual_reason = TRUE
358WHERE user_id = $1
359  AND exercise_id = $2
360  AND course_id = $3
361  AND deleted_at IS NULL
362    ",
363        user_id,
364        exercise_id,
365        course_id
366    )
367    .execute(conn)
368    .await?;
369    Ok(())
370}
371
372pub async fn get_entries_that_need_reviews_and_are_older_than(
373    conn: &mut PgConnection,
374    course_id: Uuid,
375    timestamp: DateTime<Utc>,
376) -> ModelResult<Vec<PeerReviewQueueEntry>> {
377    let res = sqlx::query_as!(
378        PeerReviewQueueEntry,
379        "
380SELECT *
381FROM peer_review_queue_entries
382WHERE course_id = $1
383  AND received_enough_peer_reviews = FALSE
384  AND removed_from_queue_for_unusual_reason = FALSE
385  AND created_at < $2
386  AND deleted_at IS NULL
387    ",
388        course_id,
389        timestamp
390    )
391    .fetch_all(&mut *conn)
392    .await?;
393    Ok(res)
394}
395
396/// Returns entries that have been waiting for teacher to review them for more than `timestamp`. The teacher is supposed to review an answer when the `user_exercise_states` `reviewing_stage` is `WaitingForManualGrading`.
397pub async fn get_entries_that_need_teacher_review_and_are_older_than_with_course_id(
398    conn: &mut PgConnection,
399    course_id: Uuid,
400    timestamp: DateTime<Utc>,
401) -> ModelResult<Vec<PeerReviewQueueEntry>> {
402    let res = sqlx::query_as!(
403        PeerReviewQueueEntry,
404        "
405SELECT prqe.*
406FROM peer_review_queue_entries prqe
407  JOIN user_exercise_states ues ON (
408    prqe.user_id = ues.user_id
409    AND prqe.exercise_id = ues.exercise_id
410    AND prqe.course_id = ues.course_id
411  )
412WHERE prqe.course_id = $1
413  AND ues.reviewing_stage = 'waiting_for_manual_grading'
414  AND prqe.created_at < $2
415  AND prqe.deleted_at IS NULL
416  AND ues.deleted_at IS NULL
417    ",
418        course_id,
419        timestamp
420    )
421    .fetch_all(conn)
422    .await?;
423    Ok(res)
424}
425
426pub async fn get_entries_that_need_reviews_and_are_older_than_with_exercise_id(
427    conn: &mut PgConnection,
428    exercise_id: Uuid,
429    timestamp: DateTime<Utc>,
430) -> ModelResult<Vec<PeerReviewQueueEntry>> {
431    let res = sqlx::query_as!(
432        PeerReviewQueueEntry,
433        "
434SELECT *
435FROM peer_review_queue_entries
436WHERE exercise_id = $1
437  AND received_enough_peer_reviews = FALSE
438  AND removed_from_queue_for_unusual_reason = FALSE
439  AND created_at < $2
440  AND deleted_at IS NULL
441    ",
442        exercise_id,
443        timestamp
444    )
445    .fetch_all(&mut *conn)
446    .await?;
447    Ok(res)
448}
449
450pub async fn remove_from_queue_and_add_to_manual_review(
451    conn: &mut PgConnection,
452    peer_review_queue_entry: &PeerReviewQueueEntry,
453) -> ModelResult<PeerReviewQueueEntry> {
454    let mut tx = conn.begin().await?;
455    let res = remove_from_queue(&mut tx, peer_review_queue_entry).await?;
456
457    let _ues = user_exercise_states::update_reviewing_stage(
458        &mut tx,
459        peer_review_queue_entry.user_id,
460        CourseOrExamId::Course(peer_review_queue_entry.course_id),
461        peer_review_queue_entry.exercise_id,
462        ReviewingStage::WaitingForManualGrading,
463    )
464    .await?;
465    tx.commit().await?;
466    Ok(res)
467}
468
469pub async fn remove_from_queue_and_give_full_points(
470    conn: &mut PgConnection,
471    peer_review_queue_entry: &PeerReviewQueueEntry,
472) -> ModelResult<PeerReviewQueueEntry> {
473    let mut tx = conn.begin().await?;
474    let res = remove_from_queue(&mut tx, peer_review_queue_entry).await?;
475    let exercise = exercises::get_by_id(&mut tx, peer_review_queue_entry.exercise_id).await?;
476    let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
477        &mut tx,
478        peer_review_queue_entry.user_id,
479        peer_review_queue_entry.exercise_id,
480        CourseOrExamId::Course(peer_review_queue_entry.course_id),
481    )
482    .await?;
483    if let Some(user_exercise_state) = user_exercise_state {
484        teacher_grading_decisions::add_teacher_grading_decision(
485            &mut tx,
486            user_exercise_state.id,
487            teacher_grading_decisions::TeacherDecisionType::FullPoints,
488            exercise.score_maximum as f32,
489            // Giver is none because the system made the decision
490            None,
491            None,
492            false,
493        )
494        .await?;
495        user_exercise_state_updater::update_user_exercise_state(&mut tx, user_exercise_state.id)
496            .await?;
497    } else {
498        return Err(ModelError::new(
499            ModelErrorType::InvalidRequest,
500            "User exercise state not found".to_string(),
501            None,
502        ));
503    }
504
505    tx.commit().await?;
506    Ok(res)
507}
508
509async fn remove_from_queue(
510    conn: &mut PgConnection,
511    peer_review_queue_entry: &PeerReviewQueueEntry,
512) -> ModelResult<PeerReviewQueueEntry> {
513    let res = sqlx::query_as!(
514        PeerReviewQueueEntry,
515        "
516UPDATE peer_review_queue_entries
517SET removed_from_queue_for_unusual_reason = TRUE
518WHERE id = $1
519RETURNING *
520    ",
521        peer_review_queue_entry.id
522    )
523    .fetch_one(&mut *conn)
524    .await?;
525
526    Ok(res)
527}
528
529pub async fn delete_by_receiving_peer_reviews_exercise_slide_submission_id(
530    conn: &mut PgConnection,
531    receiving_peer_reviews_exercise_slide_submission_id: Uuid,
532) -> ModelResult<()> {
533    sqlx::query_as!(
534        PeerReviewQueueEntry,
535        "
536UPDATE peer_review_queue_entries
537SET deleted_at = now()
538WHERE receiving_peer_reviews_exercise_slide_submission_id = $1
539AND deleted_at is NULL
540    ",
541        receiving_peer_reviews_exercise_slide_submission_id
542    )
543    .execute(&mut *conn)
544    .await?;
545
546    Ok(())
547}
548
549pub async fn get_by_receiving_peer_reviews_exercise_slide_submission_id(
550    conn: &mut PgConnection,
551    receiving_peer_reviews_exercise_slide_submission_id: Uuid,
552) -> ModelResult<PeerReviewQueueEntry> {
553    let res = sqlx::query_as!(
554        PeerReviewQueueEntry,
555        "
556SELECT *
557FROM peer_review_queue_entries
558WHERE receiving_peer_reviews_exercise_slide_submission_id = $1
559  AND deleted_at IS NULL
560",
561        receiving_peer_reviews_exercise_slide_submission_id
562    )
563    .fetch_one(conn)
564    .await?;
565    Ok(res)
566}
567
568pub async fn get_all_by_user_and_course_id(
569    conn: &mut PgConnection,
570    user_id: Uuid,
571    course_id: Uuid,
572) -> ModelResult<Vec<PeerReviewQueueEntry>> {
573    let res = sqlx::query_as!(
574        PeerReviewQueueEntry,
575        "
576SELECT *
577FROM peer_review_queue_entries
578WHERE user_id = $1
579  AND course_id = $2
580  AND deleted_at IS NULL
581        ",
582        user_id,
583        course_id,
584    )
585    .fetch_all(conn)
586    .await?;
587    Ok(res)
588}
589
590pub async fn try_to_get_all_by_user_and_course_id(
591    conn: &mut PgConnection,
592    user_id: Uuid,
593    course_id: Uuid,
594) -> ModelResult<Option<Vec<PeerReviewQueueEntry>>> {
595    get_all_by_user_and_course_id(conn, user_id, course_id)
596        .await
597        .optional()
598}