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