1use futures::future::BoxFuture;
2use url::Url;
3
4use crate::{
5 exercise_service_info::ExerciseServiceInfoApi,
6 exercises::{self, Exercise},
7 library::{self, peer_or_self_reviewing::CourseMaterialPeerOrSelfReviewData},
8 peer_or_self_review_questions::{
9 CmsPeerOrSelfReviewQuestion,
10 delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids,
11 upsert_multiple_peer_or_self_review_questions,
12 },
13 prelude::*,
14 user_exercise_states::{self, ReviewingStage},
15};
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
18#[cfg_attr(feature = "ts_rs", derive(TS))]
19pub struct PeerOrSelfReviewConfig {
20 pub id: Uuid,
21 pub created_at: DateTime<Utc>,
22 pub updated_at: DateTime<Utc>,
23 pub deleted_at: Option<DateTime<Utc>>,
24 pub course_id: Uuid,
25 pub exercise_id: Option<Uuid>,
26 pub peer_reviews_to_give: i32,
27 pub peer_reviews_to_receive: i32,
28 pub accepting_threshold: f32,
29 pub processing_strategy: PeerReviewProcessingStrategy,
30 pub manual_review_cutoff_in_days: i32,
31 pub points_are_all_or_nothing: bool,
32 pub review_instructions: Option<serde_json::Value>,
33}
34
35#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
37#[cfg_attr(feature = "ts_rs", derive(TS))]
38pub struct CourseMaterialPeerOrSelfReviewConfig {
39 pub id: Uuid,
40 pub course_id: Uuid,
41 pub exercise_id: Option<Uuid>,
42 pub peer_reviews_to_give: i32,
43 pub peer_reviews_to_receive: i32,
44}
45
46#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
47#[cfg_attr(feature = "ts_rs", derive(TS))]
48pub struct CmsPeerOrSelfReviewConfig {
49 pub id: Uuid,
50 pub course_id: Uuid,
51 pub exercise_id: Option<Uuid>,
52 pub peer_reviews_to_give: i32,
53 pub peer_reviews_to_receive: i32,
54 pub accepting_threshold: f32,
55 pub processing_strategy: PeerReviewProcessingStrategy,
56 pub points_are_all_or_nothing: bool,
57 pub review_instructions: Option<serde_json::Value>,
58}
59
60#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
61#[cfg_attr(feature = "ts_rs", derive(TS))]
62pub struct CmsPeerOrSelfReviewConfiguration {
63 pub peer_or_self_review_config: CmsPeerOrSelfReviewConfig,
64 pub peer_or_self_review_questions: Vec<CmsPeerOrSelfReviewQuestion>,
65}
66
67#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type)]
73#[cfg_attr(feature = "ts_rs", derive(TS))]
74#[sqlx(
75 type_name = "peer_review_processing_strategy",
76 rename_all = "snake_case"
77)]
78pub enum PeerReviewProcessingStrategy {
79 AutomaticallyGradeByAverage,
81 AutomaticallyGradeOrManualReviewByAverage,
83 ManualReviewEverything,
85}
86
87pub async fn insert(
88 conn: &mut PgConnection,
89 pkey_policy: PKeyPolicy<Uuid>,
90 course_id: Uuid,
91 exercise_id: Option<Uuid>,
92) -> ModelResult<Uuid> {
93 let res = sqlx::query!(
94 "
95INSERT INTO peer_or_self_review_configs (id, course_id, exercise_id)
96VALUES ($1, $2, $3)
97RETURNING id
98 ",
99 pkey_policy.into_uuid(),
100 course_id,
101 exercise_id,
102 )
103 .fetch_one(conn)
104 .await?;
105 Ok(res.id)
106}
107
108pub async fn upsert_with_id(
109 conn: &mut PgConnection,
110 pkey_policy: PKeyPolicy<Uuid>,
111 cms_peer_review: &CmsPeerOrSelfReviewConfig,
112) -> ModelResult<CmsPeerOrSelfReviewConfig> {
113 let res = sqlx::query_as!(
114 CmsPeerOrSelfReviewConfig,
115 r#"
116 INSERT INTO peer_or_self_review_configs (
117 id,
118 course_id,
119 exercise_id,
120 peer_reviews_to_give,
121 peer_reviews_to_receive,
122 accepting_threshold,
123 processing_strategy,
124 points_are_all_or_nothing,
125 review_instructions
126 )
127VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO
128UPDATE
129SET course_id = excluded.course_id,
130 exercise_id = excluded.exercise_id,
131 peer_reviews_to_give = excluded.peer_reviews_to_give,
132 peer_reviews_to_receive = excluded.peer_reviews_to_receive,
133 accepting_threshold = excluded.accepting_threshold,
134 processing_strategy = excluded.processing_strategy,
135 points_are_all_or_nothing = excluded.points_are_all_or_nothing,
136 review_instructions = excluded.review_instructions
137RETURNING id,
138 course_id,
139 exercise_id,
140 peer_reviews_to_give,
141 peer_reviews_to_receive,
142 accepting_threshold,
143 processing_strategy AS "processing_strategy:_",
144 points_are_all_or_nothing,
145 review_instructions
146"#,
147 pkey_policy.into_uuid(),
148 cms_peer_review.course_id,
149 cms_peer_review.exercise_id,
150 cms_peer_review.peer_reviews_to_give,
151 cms_peer_review.peer_reviews_to_receive,
152 cms_peer_review.accepting_threshold,
153 cms_peer_review.processing_strategy as _,
154 cms_peer_review.points_are_all_or_nothing,
155 cms_peer_review.review_instructions,
156 )
157 .fetch_one(conn)
158 .await?;
159 Ok(res)
160}
161
162pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<PeerOrSelfReviewConfig> {
163 let res = sqlx::query_as!(
164 PeerOrSelfReviewConfig,
165 r#"
166SELECT id,
167 created_at,
168 updated_at,
169 deleted_at,
170 course_id,
171 exercise_id,
172 peer_reviews_to_give,
173 peer_reviews_to_receive,
174 accepting_threshold,
175 processing_strategy AS "processing_strategy: _",
176 manual_review_cutoff_in_days,
177 points_are_all_or_nothing,
178 review_instructions
179FROM peer_or_self_review_configs
180WHERE id = $1
181 AND deleted_at IS NULL
182 "#,
183 id
184 )
185 .fetch_one(conn)
186 .await?;
187 Ok(res)
188}
189
190pub async fn get_by_exercise_id(
192 conn: &mut PgConnection,
193 exercise_id: Uuid,
194) -> ModelResult<PeerOrSelfReviewConfig> {
195 let res = sqlx::query_as!(
196 PeerOrSelfReviewConfig,
197 r#"
198SELECT id,
199 created_at,
200 updated_at,
201 deleted_at,
202 course_id,
203 exercise_id,
204 peer_reviews_to_give,
205 peer_reviews_to_receive,
206 accepting_threshold,
207 processing_strategy AS "processing_strategy: _",
208 manual_review_cutoff_in_days,
209 points_are_all_or_nothing,
210 review_instructions
211FROM peer_or_self_review_configs
212WHERE exercise_id = $1
213 AND deleted_at IS NULL
214 "#,
215 exercise_id
216 )
217 .fetch_one(conn)
218 .await?;
219 Ok(res)
220}
221
222pub async fn get_by_exercise_or_course_id(
224 conn: &mut PgConnection,
225 exercise: &Exercise,
226 course_id: Uuid,
227) -> ModelResult<PeerOrSelfReviewConfig> {
228 if exercise.use_course_default_peer_or_self_review_config {
229 get_default_for_course_by_course_id(conn, course_id).await
230 } else {
231 get_by_exercise_id(conn, exercise.id).await
232 }
233}
234
235pub async fn get_default_for_course_by_course_id(
236 conn: &mut PgConnection,
237 course_id: Uuid,
238) -> ModelResult<PeerOrSelfReviewConfig> {
239 let res = sqlx::query_as!(
240 PeerOrSelfReviewConfig,
241 r#"
242SELECT id,
243 created_at,
244 updated_at,
245 deleted_at,
246 course_id,
247 exercise_id,
248 peer_reviews_to_give,
249 peer_reviews_to_receive,
250 accepting_threshold,
251 processing_strategy AS "processing_strategy: _",
252 manual_review_cutoff_in_days,
253 points_are_all_or_nothing,
254 review_instructions
255FROM peer_or_self_review_configs
256WHERE course_id = $1
257 AND exercise_id IS NULL
258 AND deleted_at IS NULL;
259 "#,
260 course_id
261 )
262 .fetch_one(conn)
263 .await?;
264 Ok(res)
265}
266
267pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
268 let res = sqlx::query!(
269 "
270UPDATE peer_or_self_review_configs
271SET deleted_at = now()
272WHERE id = $1
273RETURNING id
274 ",
275 id
276 )
277 .fetch_one(conn)
278 .await?;
279 Ok(res.id)
280}
281
282pub async fn get_course_material_peer_or_self_review_data(
283 conn: &mut PgConnection,
284 user_id: Uuid,
285 exercise_id: Uuid,
286 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
287) -> ModelResult<CourseMaterialPeerOrSelfReviewData> {
288 let exercise = exercises::get_by_id(conn, exercise_id).await?;
289 let (_current_exercise_slide, instance_or_exam_id) = exercises::get_or_select_exercise_slide(
290 &mut *conn,
291 Some(user_id),
292 &exercise,
293 &fetch_service_info,
294 )
295 .await?;
296
297 let user_exercise_state = match instance_or_exam_id {
298 Some(course_instance_or_exam_id) => {
299 user_exercise_states::get_user_exercise_state_if_exists(
300 conn,
301 user_id,
302 exercise.id,
303 course_instance_or_exam_id,
304 )
305 .await?
306 }
307 _ => None,
308 };
309
310 match user_exercise_state {
311 Some(ref user_exercise_state) => {
312 if matches!(
313 user_exercise_state.reviewing_stage,
314 ReviewingStage::PeerReview | ReviewingStage::WaitingForPeerReviews
315 ) {
316 let res = library::peer_or_self_reviewing::try_to_select_exercise_slide_submission_for_peer_review(
319 conn,
320 &exercise,
321 user_exercise_state,
322 &fetch_service_info
323 )
324 .await?;
325 Ok(res)
326 } else if user_exercise_state.reviewing_stage == ReviewingStage::SelfReview {
327 let res = library::peer_or_self_reviewing::select_own_submission_for_self_review(
328 conn,
329 &exercise,
330 user_exercise_state,
331 &fetch_service_info,
332 )
333 .await?;
334 Ok(res)
335 } else {
336 Err(ModelError::new(
337 ModelErrorType::PreconditionFailed,
338 "You cannot peer review yet".to_string(),
339 None,
340 ))
341 }
342 }
343 None => Err(ModelError::new(
344 ModelErrorType::InvalidRequest,
345 "You haven't answered this exercise".to_string(),
346 None,
347 )),
348 }
349}
350
351pub async fn get_peer_reviews_by_page_id(
352 conn: &mut PgConnection,
353 page_id: Uuid,
354) -> ModelResult<Vec<CmsPeerOrSelfReviewConfig>> {
355 let res = sqlx::query_as!(
356 CmsPeerOrSelfReviewConfig,
357 r#"
358SELECT pr.id as id,
359 pr.course_id as course_id,
360 pr.exercise_id as exercise_id,
361 pr.peer_reviews_to_give as peer_reviews_to_give,
362 pr.peer_reviews_to_receive as peer_reviews_to_receive,
363 pr.accepting_threshold as accepting_threshold,
364 pr.processing_strategy AS "processing_strategy: _",
365 points_are_all_or_nothing,
366 pr.review_instructions
367from pages p
368 join exercises e on p.id = e.page_id
369 join peer_or_self_review_configs pr on e.id = pr.exercise_id
370where p.id = $1
371 AND p.deleted_at IS NULL
372 AND e.deleted_at IS NULL
373 AND pr.deleted_at IS NULL;
374 "#,
375 page_id,
376 )
377 .fetch_all(conn)
378 .await?;
379
380 Ok(res)
381}
382
383pub async fn delete_peer_reviews_by_exrcise_ids(
384 conn: &mut PgConnection,
385 exercise_ids: &[Uuid],
386) -> ModelResult<Vec<Uuid>> {
387 let res = sqlx::query!(
388 "
389UPDATE peer_or_self_review_configs
390SET deleted_at = now()
391WHERE exercise_id = ANY ($1)
392AND deleted_at IS NULL
393RETURNING id;
394 ",
395 exercise_ids
396 )
397 .fetch_all(conn)
398 .await?
399 .into_iter()
400 .map(|x| x.id)
401 .collect();
402 Ok(res)
403}
404
405pub async fn get_course_default_cms_peer_review(
406 conn: &mut PgConnection,
407 course_id: Uuid,
408) -> ModelResult<CmsPeerOrSelfReviewConfig> {
409 let res = sqlx::query_as!(
410 CmsPeerOrSelfReviewConfig,
411 r#"
412SELECT id,
413 course_id,
414 exercise_id,
415 peer_reviews_to_give,
416 peer_reviews_to_receive,
417 accepting_threshold,
418 processing_strategy AS "processing_strategy: _",
419 points_are_all_or_nothing,
420 review_instructions
421FROM peer_or_self_review_configs
422WHERE course_id = $1
423 AND exercise_id IS NULL
424 AND deleted_at IS NULL;
425"#,
426 course_id
427 )
428 .fetch_one(conn)
429 .await?;
430 Ok(res)
431}
432
433pub async fn get_cms_peer_review_by_id(
434 conn: &mut PgConnection,
435 peer_or_self_review_config_id: Uuid,
436) -> ModelResult<CmsPeerOrSelfReviewConfig> {
437 let res = sqlx::query_as!(
438 CmsPeerOrSelfReviewConfig,
439 r#"
440SELECT id,
441 course_id,
442 exercise_id,
443 peer_reviews_to_give,
444 peer_reviews_to_receive,
445 accepting_threshold,
446 processing_strategy AS "processing_strategy:_",
447 points_are_all_or_nothing,
448 review_instructions
449FROM peer_or_self_review_configs
450WHERE id = $1;
451 "#,
452 peer_or_self_review_config_id
453 )
454 .fetch_one(conn)
455 .await?;
456 Ok(res)
457}
458
459pub async fn upsert_course_default_cms_peer_review_and_questions(
460 conn: &mut PgConnection,
461 peer_or_self_review_configuration: &CmsPeerOrSelfReviewConfiguration,
462) -> ModelResult<CmsPeerOrSelfReviewConfiguration> {
463 let peer_or_self_review_config = upsert_with_id(
465 conn,
466 PKeyPolicy::Fixed(
467 peer_or_self_review_configuration
468 .peer_or_self_review_config
469 .id,
470 ),
471 &peer_or_self_review_configuration.peer_or_self_review_config,
472 )
473 .await?;
474
475 let previous_peer_or_self_review_question_ids =
477 delete_peer_or_self_review_questions_by_peer_or_self_review_config_ids(
478 conn,
479 &[peer_or_self_review_config.id],
480 )
481 .await?;
482 let peer_or_self_review_questions = upsert_multiple_peer_or_self_review_questions(
483 conn,
484 &peer_or_self_review_configuration
485 .peer_or_self_review_questions
486 .iter()
487 .map(|prq| {
488 let id = if previous_peer_or_self_review_question_ids.contains(&prq.id) {
489 prq.id
490 } else {
491 Uuid::new_v4()
492 };
493 CmsPeerOrSelfReviewQuestion { id, ..prq.clone() }
494 })
495 .collect::<Vec<_>>(),
496 )
497 .await?;
498
499 Ok(CmsPeerOrSelfReviewConfiguration {
500 peer_or_self_review_config,
501 peer_or_self_review_questions,
502 })
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use crate::test_helper::*;
509
510 #[tokio::test]
511 async fn only_one_default_peer_review_per_course() {
512 insert_data!(:tx, :user, :org, :course);
513
514 let peer_review_1 = insert(tx.as_mut(), PKeyPolicy::Generate, course, None).await;
515 assert!(peer_review_1.is_err());
516 }
517}