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_instance_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_instance_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_instance_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_instance_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_instance_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_instance_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_instance_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_instance_id, deleted_at) DO
86UPDATE
87SET peer_review_priority = $4
88RETURNING *
89        ",
90        user_id,
91        exercise_id,
92        course_instance_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
164pub async fn get_by_user_and_exercise_and_course_instance_ids(
165    conn: &mut PgConnection,
166    user_id: Uuid,
167    exercise_id: Uuid,
168    course_instance_id: Uuid,
169) -> ModelResult<PeerReviewQueueEntry> {
170    let res = sqlx::query_as!(
171        PeerReviewQueueEntry,
172        "
173SELECT *
174FROM peer_review_queue_entries
175WHERE user_id = $1
176  AND exercise_id = $2
177  AND course_instance_id = $3
178  AND deleted_at IS NULL
179        ",
180        user_id,
181        exercise_id,
182        course_instance_id,
183    )
184    .fetch_one(conn)
185    .await?;
186    Ok(res)
187}
188
189pub async fn try_to_get_by_user_and_exercise_and_course_instance_ids(
190    conn: &mut PgConnection,
191    user_id: Uuid,
192    exercise_id: Uuid,
193    course_instance_id: Uuid,
194) -> ModelResult<Option<PeerReviewQueueEntry>> {
195    get_by_user_and_exercise_and_course_instance_ids(conn, user_id, exercise_id, course_instance_id)
196        .await
197        .optional()
198}
199
200/// Gets multiple records of `PeerReviewQueueEntry` ordered by newest queue entries first. Also returns entries that don't need peer review.
201///
202/// Doesn't differentiate between different course instances.
203pub async fn get_any_including_not_needing_review(
204    conn: &mut PgConnection,
205    exercise_id: Uuid,
206    excluded_user_id: Uuid,
207    excluded_submissions_ids: &[Uuid],
208    count: i64,
209) -> ModelResult<Vec<PeerReviewQueueEntry>> {
210    let res = sqlx::query_as!(
211        PeerReviewQueueEntry,
212        "
213SELECT *
214FROM peer_review_queue_entries
215WHERE exercise_id = $1
216  AND user_id <> $2
217  AND receiving_peer_reviews_exercise_slide_submission_id <> ALL($3)
218  AND deleted_at IS NULL
219ORDER BY created_at DESC
220LIMIT $4
221            ",
222        exercise_id,
223        excluded_user_id,
224        excluded_submissions_ids,
225        count,
226    )
227    .fetch_all(conn)
228    .await?;
229    Ok(res)
230}
231
232/// Gets multiple records of `PeerReviewQueueEntry` that still require more peer reviews, ordered by
233/// peer review priority.
234///
235/// Doesn't differentiate between different course instances.
236pub async fn get_many_that_need_peer_reviews_by_exercise_id_and_review_priority(
237    conn: &mut PgConnection,
238    exercise_id: Uuid,
239    excluded_user_id: Uuid,
240    excluded_submissions_ids: &[Uuid],
241    count: i64,
242) -> ModelResult<Vec<PeerReviewQueueEntry>> {
243    let res = sqlx::query_as!(
244        PeerReviewQueueEntry,
245        "
246SELECT *
247FROM peer_review_queue_entries
248WHERE exercise_id = $1
249  AND user_id <> $2
250  AND receiving_peer_reviews_exercise_slide_submission_id <> ALL($3)
251  AND received_enough_peer_reviews = 'false'
252  AND removed_from_queue_for_unusual_reason = 'false'
253  AND deleted_at IS NULL
254ORDER BY peer_review_priority DESC
255LIMIT $4
256        ",
257        exercise_id,
258        excluded_user_id,
259        excluded_submissions_ids,
260        count,
261    )
262    .fetch_all(conn)
263    .await?;
264    Ok(res)
265}
266
267/// Gets multiple records of `PeerReviewQueueEntry` that still require more peer reviews, ordered by
268/// peer review priority.
269///
270/// Doesn't differentiate between different course instances.
271pub async fn get_all_that_need_peer_reviews_by_exercise_id(
272    conn: &mut PgConnection,
273    exercise_id: Uuid,
274) -> ModelResult<Vec<PeerReviewQueueEntry>> {
275    let res = sqlx::query_as!(
276        PeerReviewQueueEntry,
277        "
278SELECT *
279FROM peer_review_queue_entries
280WHERE exercise_id = $1
281  AND received_enough_peer_reviews = 'false'
282  AND deleted_at IS NULL
283        ",
284        exercise_id,
285    )
286    .fetch_all(conn)
287    .await?;
288    Ok(res)
289}
290
291pub async fn increment_peer_review_priority(
292    conn: &mut PgConnection,
293    peer_review_queue_entry: PeerReviewQueueEntry,
294) -> ModelResult<PeerReviewQueueEntry> {
295    let res = sqlx::query_as!(
296        PeerReviewQueueEntry,
297        "
298UPDATE peer_review_queue_entries
299SET peer_review_priority = $1
300WHERE id = $2
301  AND deleted_at IS NULL
302RETURNING *
303    ",
304        peer_review_queue_entry.peer_review_priority + 1,
305        peer_review_queue_entry.id
306    )
307    .fetch_one(conn)
308    .await?;
309    Ok(res)
310}
311
312pub async fn remove_queue_entries_for_unusual_reason(
313    conn: &mut PgConnection,
314    user_id: Uuid,
315    exercise_id: Uuid,
316    course_instance_id: Uuid,
317) -> ModelResult<()> {
318    sqlx::query!(
319        "
320UPDATE peer_review_queue_entries
321SET removed_from_queue_for_unusual_reason = TRUE
322WHERE user_id = $1
323  AND exercise_id = $2
324  AND course_instance_id = $3
325  AND deleted_at IS NULL
326    ",
327        user_id,
328        exercise_id,
329        course_instance_id
330    )
331    .execute(conn)
332    .await?;
333    Ok(())
334}
335
336pub async fn get_entries_that_need_reviews_and_are_older_than(
337    conn: &mut PgConnection,
338    course_instance_id: Uuid,
339    timestamp: DateTime<Utc>,
340) -> ModelResult<Vec<PeerReviewQueueEntry>> {
341    let res = sqlx::query_as!(
342        PeerReviewQueueEntry,
343        "
344SELECT *
345FROM peer_review_queue_entries
346WHERE course_instance_id = $1
347  AND received_enough_peer_reviews = FALSE
348  AND removed_from_queue_for_unusual_reason = FALSE
349  AND created_at < $2
350  AND deleted_at IS NULL
351    ",
352        course_instance_id,
353        timestamp
354    )
355    .fetch_all(&mut *conn)
356    .await?;
357    Ok(res)
358}
359
360/// 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`.
361pub async fn get_entries_that_need_teacher_review_and_are_older_than_with_course_instance_id(
362    conn: &mut PgConnection,
363    course_instance_id: Uuid,
364    timestamp: DateTime<Utc>,
365) -> ModelResult<Vec<PeerReviewQueueEntry>> {
366    let res = sqlx::query_as!(
367        PeerReviewQueueEntry,
368        "
369SELECT prqe.*
370FROM peer_review_queue_entries prqe
371  JOIN user_exercise_states ues ON (
372    prqe.user_id = ues.user_id
373    AND prqe.exercise_id = ues.exercise_id
374    AND prqe.course_instance_id = ues.course_instance_id
375  )
376WHERE prqe.course_instance_id = $1
377  AND ues.reviewing_stage = 'waiting_for_manual_grading'
378  AND prqe.created_at < $2
379  AND prqe.deleted_at IS NULL
380  AND ues.deleted_at IS NULL
381    ",
382        course_instance_id,
383        timestamp
384    )
385    .fetch_all(conn)
386    .await?;
387    Ok(res)
388}
389
390pub async fn get_entries_that_need_reviews_and_are_older_than_with_exercise_id(
391    conn: &mut PgConnection,
392    exercise_id: Uuid,
393    timestamp: DateTime<Utc>,
394) -> ModelResult<Vec<PeerReviewQueueEntry>> {
395    let res = sqlx::query_as!(
396        PeerReviewQueueEntry,
397        "
398SELECT *
399FROM peer_review_queue_entries
400WHERE exercise_id = $1
401  AND received_enough_peer_reviews = FALSE
402  AND removed_from_queue_for_unusual_reason = FALSE
403  AND created_at < $2
404  AND deleted_at IS NULL
405    ",
406        exercise_id,
407        timestamp
408    )
409    .fetch_all(&mut *conn)
410    .await?;
411    Ok(res)
412}
413
414pub async fn remove_from_queue_and_add_to_manual_review(
415    conn: &mut PgConnection,
416    peer_review_queue_entry: &PeerReviewQueueEntry,
417) -> ModelResult<PeerReviewQueueEntry> {
418    let mut tx = conn.begin().await?;
419    let res = remove_from_queue(&mut tx, peer_review_queue_entry).await?;
420
421    let _ues = user_exercise_states::update_reviewing_stage(
422        &mut tx,
423        peer_review_queue_entry.user_id,
424        user_exercise_states::CourseInstanceOrExamId::Instance(
425            peer_review_queue_entry.course_instance_id,
426        ),
427        peer_review_queue_entry.exercise_id,
428        ReviewingStage::WaitingForManualGrading,
429    )
430    .await?;
431    tx.commit().await?;
432    Ok(res)
433}
434
435pub async fn remove_from_queue_and_give_full_points(
436    conn: &mut PgConnection,
437    peer_review_queue_entry: &PeerReviewQueueEntry,
438) -> ModelResult<PeerReviewQueueEntry> {
439    let mut tx = conn.begin().await?;
440    let res = remove_from_queue(&mut tx, peer_review_queue_entry).await?;
441    let exercise = exercises::get_by_id(&mut tx, peer_review_queue_entry.exercise_id).await?;
442    let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
443        &mut tx,
444        peer_review_queue_entry.user_id,
445        peer_review_queue_entry.exercise_id,
446        user_exercise_states::CourseInstanceOrExamId::Instance(
447            peer_review_queue_entry.course_instance_id,
448        ),
449    )
450    .await?;
451    if let Some(user_exercise_state) = user_exercise_state {
452        teacher_grading_decisions::add_teacher_grading_decision(
453            &mut tx,
454            user_exercise_state.id,
455            teacher_grading_decisions::TeacherDecisionType::FullPoints,
456            exercise.score_maximum as f32,
457            // Giver is none because the system made the decision
458            None,
459            None,
460            false,
461        )
462        .await?;
463        user_exercise_state_updater::update_user_exercise_state(&mut tx, user_exercise_state.id)
464            .await?;
465    } else {
466        return Err(ModelError::new(
467            ModelErrorType::InvalidRequest,
468            "User exercise state not found".to_string(),
469            None,
470        ));
471    }
472
473    tx.commit().await?;
474    Ok(res)
475}
476
477async fn remove_from_queue(
478    conn: &mut PgConnection,
479    peer_review_queue_entry: &PeerReviewQueueEntry,
480) -> ModelResult<PeerReviewQueueEntry> {
481    let res = sqlx::query_as!(
482        PeerReviewQueueEntry,
483        "
484UPDATE peer_review_queue_entries
485SET removed_from_queue_for_unusual_reason = TRUE
486WHERE id = $1
487RETURNING *
488    ",
489        peer_review_queue_entry.id
490    )
491    .fetch_one(&mut *conn)
492    .await?;
493
494    Ok(res)
495}
496
497pub async fn delete_by_receiving_peer_reviews_exercise_slide_submission_id(
498    conn: &mut PgConnection,
499    receiving_peer_reviews_exercise_slide_submission_id: Uuid,
500) -> ModelResult<()> {
501    sqlx::query_as!(
502        PeerReviewQueueEntry,
503        "
504UPDATE peer_review_queue_entries
505SET deleted_at = now()
506WHERE receiving_peer_reviews_exercise_slide_submission_id = $1
507AND deleted_at is NULL
508    ",
509        receiving_peer_reviews_exercise_slide_submission_id
510    )
511    .execute(&mut *conn)
512    .await?;
513
514    Ok(())
515}
516
517pub async fn get_by_receiving_peer_reviews_exercise_slide_submission_id(
518    conn: &mut PgConnection,
519    receiving_peer_reviews_exercise_slide_submission_id: Uuid,
520) -> ModelResult<PeerReviewQueueEntry> {
521    let res = sqlx::query_as!(
522        PeerReviewQueueEntry,
523        "
524SELECT *
525FROM peer_review_queue_entries
526WHERE receiving_peer_reviews_exercise_slide_submission_id = $1
527  AND deleted_at IS NULL
528",
529        receiving_peer_reviews_exercise_slide_submission_id
530    )
531    .fetch_one(conn)
532    .await?;
533    Ok(res)
534}
535
536pub async fn get_all_by_user_and_course_instance_ids(
537    conn: &mut PgConnection,
538    user_id: Uuid,
539    course_instance_id: Uuid,
540) -> ModelResult<Vec<PeerReviewQueueEntry>> {
541    let res = sqlx::query_as!(
542        PeerReviewQueueEntry,
543        "
544SELECT *
545FROM peer_review_queue_entries
546WHERE user_id = $1
547  AND course_instance_id = $2
548  AND deleted_at IS NULL
549        ",
550        user_id,
551        course_instance_id,
552    )
553    .fetch_all(conn)
554    .await?;
555    Ok(res)
556}
557
558pub async fn try_to_get_all_by_user_and_course_instance_ids(
559    conn: &mut PgConnection,
560    user_id: Uuid,
561    course_instance_id: Uuid,
562) -> ModelResult<Option<Vec<PeerReviewQueueEntry>>> {
563    get_all_by_user_and_course_instance_ids(conn, user_id, course_instance_id)
564        .await
565        .optional()
566}