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