1use chrono::Duration;
2use std::collections::HashMap;
3use utoipa::ToSchema;
4
5use crate::{courses::Course, prelude::*};
6use headless_lms_utils::document_schema_processor::GutenbergBlock;
7#[derive(Debug, Serialize, ToSchema)]
8
9pub struct Exam {
10 pub id: Uuid,
11 pub name: String,
12 pub instructions: serde_json::Value,
13 pub page_id: Uuid,
15 pub courses: Vec<Course>,
16 pub starts_at: Option<DateTime<Utc>>,
17 pub ends_at: Option<DateTime<Utc>>,
18 pub time_minutes: i32,
19 pub minimum_points_treshold: i32,
20 pub language: String,
21 pub grade_manually: bool,
22}
23
24#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
25pub struct ExamIdentity {
26 pub id: Uuid,
27 pub created_at: DateTime<Utc>,
28 pub updated_at: DateTime<Utc>,
29 pub deleted_at: Option<DateTime<Utc>>,
30 pub organization_id: Uuid,
31 pub name: String,
32 pub instructions: serde_json::Value,
33 pub page_id: Uuid,
34 pub starts_at: Option<DateTime<Utc>>,
35 pub ends_at: Option<DateTime<Utc>>,
36 pub time_minutes: i32,
37 pub minimum_points_treshold: i32,
38 pub language: String,
39 pub grade_manually: bool,
40}
41
42impl Exam {
43 pub fn started_at_or(&self, timestamp: DateTime<Utc>, default: bool) -> bool {
46 match self.starts_at {
47 Some(starts_at) => starts_at <= timestamp,
48 None => default,
49 }
50 }
51
52 pub fn ended_at_or(&self, timestamp: DateTime<Utc>, default: bool) -> bool {
55 match self.ends_at {
56 Some(ends_at) => ends_at < timestamp,
57 None => default,
58 }
59 }
60}
61
62#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
63
64pub struct OrgExam {
65 pub id: Uuid,
66 pub created_at: DateTime<Utc>,
67 pub updated_at: DateTime<Utc>,
68 pub deleted_at: Option<DateTime<Utc>>,
69 pub name: String,
70 pub instructions: serde_json::Value,
71 pub starts_at: Option<DateTime<Utc>>,
72 pub ends_at: Option<DateTime<Utc>>,
73 pub language: Option<String>,
74 pub time_minutes: i32,
75 pub organization_id: Uuid,
76 pub minimum_points_treshold: i32,
77 pub grade_manually: bool,
78}
79
80pub async fn get_identity_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ExamIdentity> {
82 let exam = sqlx::query_as!(
83 ExamIdentity,
84 r#"
85SELECT exams.id,
86 exams.created_at,
87 exams.updated_at,
88 exams.deleted_at,
89 exams.organization_id,
90 exams.name,
91 exams.instructions,
92 pages.id AS page_id,
93 exams.starts_at,
94 exams.ends_at,
95 exams.time_minutes,
96 exams.minimum_points_treshold,
97 COALESCE(exams.language, 'en-US') AS "language!",
98 exams.grade_manually
99FROM exams
100 JOIN pages ON pages.exam_id = exams.id
101WHERE exams.id = $1
102 AND exams.deleted_at IS NULL
103 AND pages.deleted_at IS NULL
104 "#,
105 id
106 )
107 .fetch_one(conn)
108 .await?;
109 Ok(exam)
110}
111
112pub async fn get(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exam> {
114 let exam = sqlx::query!(
115 "
116SELECT exams.id,
117 exams.name,
118 exams.instructions,
119 pages.id AS page_id,
120 exams.starts_at,
121 exams.ends_at,
122 exams.time_minutes,
123 exams.minimum_points_treshold,
124 exams.language,
125 exams.grade_manually
126FROM exams
127 JOIN pages ON pages.exam_id = exams.id
128WHERE exams.id = $1
129",
130 id
131 )
132 .fetch_one(&mut *conn)
133 .await?;
134
135 let courses = sqlx::query_as!(
136 Course,
137 r#"
138SELECT id,
139 slug,
140 courses.created_at,
141 courses.updated_at,
142 courses.deleted_at,
143 name,
144 description,
145 organization_id,
146 language_code,
147 copied_from,
148 content_search_language::text,
149 course_language_group_id,
150 is_draft,
151 is_test_mode,
152 base_module_completion_requires_n_submodule_completions,
153 can_add_chatbot,
154 is_unlisted,
155 is_joinable_by_code_only,
156 join_code,
157 ask_marketing_consent,
158 flagged_answers_threshold,
159 flagged_answers_skip_manual_review_and_allow_retry,
160 closed_at,
161 closed_additional_message,
162 closed_course_successor_id,
163 chapter_locking_enabled,
164 cheater_detection_enabled,
165 ai_policy,
166 course_material_ai_instructions
167FROM courses
168 JOIN course_exams ON courses.id = course_exams.course_id
169WHERE course_exams.exam_id = $1
170 AND courses.deleted_at IS NULL
171 AND course_exams.deleted_at IS NULL
172"#,
173 id
174 )
175 .fetch_all(&mut *conn)
176 .await?;
177
178 Ok(Exam {
179 id: exam.id,
180 name: exam.name,
181 instructions: exam.instructions,
182 page_id: exam.page_id,
183 starts_at: exam.starts_at,
184 ends_at: exam.ends_at,
185 time_minutes: exam.time_minutes,
186 courses,
187 minimum_points_treshold: exam.minimum_points_treshold,
188 language: exam.language.unwrap_or("en-US".to_string()),
189 grade_manually: exam.grade_manually,
190 })
191}
192
193#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
194
195pub struct CourseExam {
196 pub id: Uuid,
197 pub course_id: Uuid,
198 pub course_name: String,
199 pub name: String,
200}
201
202pub struct ExamSummary {
204 pub id: Uuid,
205 pub name: String,
206 pub starts_at: Option<DateTime<Utc>>,
207 pub ends_at: Option<DateTime<Utc>>,
208 pub time_minutes: i32,
209 pub minimum_points_treshold: i32,
210 pub grade_manually: bool,
211}
212
213pub async fn get_summaries_by_ids(
214 conn: &mut PgConnection,
215 ids: &[Uuid],
216) -> ModelResult<Vec<ExamSummary>> {
217 let exams = sqlx::query_as!(
218 ExamSummary,
219 "
220SELECT id,
221 name,
222 starts_at,
223 ends_at,
224 time_minutes,
225 minimum_points_treshold,
226 grade_manually
227FROM exams
228WHERE id = ANY($1)
229 AND deleted_at IS NULL
230 ",
231 ids,
232 )
233 .fetch_all(conn)
234 .await?;
235 Ok(exams)
236}
237
238#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
239
240pub struct NewExam {
241 pub name: String,
242 pub starts_at: Option<DateTime<Utc>>,
243 pub ends_at: Option<DateTime<Utc>>,
244 pub time_minutes: i32,
245 pub organization_id: Uuid,
246 pub minimum_points_treshold: i32,
247 pub grade_manually: bool,
248}
249
250#[derive(Debug, Serialize, ToSchema)]
251
252pub struct ExamInstructions {
253 pub id: Uuid,
254 pub instructions: serde_json::Value,
255}
256
257#[derive(Debug, Serialize, Deserialize, ToSchema)]
258
259pub struct ExamInstructionsUpdate {
260 pub instructions: serde_json::Value,
261}
262
263pub async fn insert(
264 conn: &mut PgConnection,
265 pkey_policy: PKeyPolicy<Uuid>,
266 exam: &NewExam,
267) -> ModelResult<Uuid> {
268 let res = sqlx::query!(
269 "
270INSERT INTO exams (
271 id,
272 name,
273 instructions,
274 starts_at,
275 ends_at,
276 time_minutes,
277 organization_id,
278 minimum_points_treshold,
279 grade_manually
280 )
281VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
282RETURNING *
283 ",
284 pkey_policy.into_uuid(),
285 exam.name,
286 serde_json::Value::Array(vec![]),
287 exam.starts_at,
288 exam.ends_at,
289 exam.time_minutes,
290 exam.organization_id,
291 exam.minimum_points_treshold,
292 exam.grade_manually,
293 )
294 .fetch_one(conn)
295 .await?;
296
297 Ok(res.id)
298}
299
300pub async fn edit(conn: &mut PgConnection, id: Uuid, new_exam: NewExam) -> ModelResult<()> {
301 sqlx::query!(
302 "
303UPDATE exams
304SET name = COALESCE($2, name),
305 starts_at = $3,
306 ends_at = $4,
307 time_minutes = $5,
308 minimum_points_treshold = $6,
309 grade_manually = $7
310WHERE id = $1
311",
312 id,
313 new_exam.name,
314 new_exam.starts_at,
315 new_exam.ends_at,
316 new_exam.time_minutes,
317 new_exam.minimum_points_treshold,
318 new_exam.grade_manually,
319 )
320 .execute(conn)
321 .await?;
322 Ok(())
323}
324
325pub async fn get_exams_for_organization(
326 conn: &mut PgConnection,
327 organization: Uuid,
328) -> ModelResult<Vec<OrgExam>> {
329 let res = sqlx::query_as!(
330 OrgExam,
331 "
332SELECT *
333FROM exams
334WHERE exams.organization_id = $1
335 AND exams.deleted_at IS NULL
336",
337 organization
338 )
339 .fetch_all(conn)
340 .await?;
341 Ok(res)
342}
343
344pub async fn get_organization_exam_with_exam_id(
345 conn: &mut PgConnection,
346 exam_id: Uuid,
347) -> ModelResult<OrgExam> {
348 let res = sqlx::query_as!(
349 OrgExam,
350 "
351SELECT *
352FROM exams
353WHERE exams.id = $1
354 AND exams.deleted_at IS NULL
355",
356 exam_id
357 )
358 .fetch_one(conn)
359 .await?;
360 Ok(res)
361}
362
363pub async fn get_course_exams_for_organization(
364 conn: &mut PgConnection,
365 organization: Uuid,
366) -> ModelResult<Vec<CourseExam>> {
367 let res = sqlx::query_as!(
368 CourseExam,
369 "
370SELECT exams.id,
371 courses.id as course_id,
372 courses.name as course_name,
373 exams.name
374FROM exams
375 JOIN course_exams ON course_exams.exam_id = exams.id
376 JOIN courses ON courses.id = course_exams.course_id
377WHERE exams.organization_id = $1
378 AND exams.deleted_at IS NULL
379 AND courses.deleted_at IS NULL
380",
381 organization
382 )
383 .fetch_all(conn)
384 .await?;
385 Ok(res)
386}
387
388pub async fn get_exams_for_course(
389 conn: &mut PgConnection,
390 course: Uuid,
391) -> ModelResult<Vec<CourseExam>> {
392 let res = sqlx::query_as!(
393 CourseExam,
394 "
395SELECT exams.id,
396 courses.id as course_id,
397 courses.name as course_name,
398 exams.name
399FROM exams
400 JOIN course_exams ON course_id = $1
401 JOIN courses ON courses.id = $1
402 AND exams.deleted_at IS NULL
403 AND courses.deleted_at IS NULL
404",
405 course
406 )
407 .fetch_all(conn)
408 .await?;
409 Ok(res)
410}
411
412pub async fn enroll(
413 conn: &mut PgConnection,
414 exam_id: Uuid,
415 user_id: Uuid,
416 is_teacher_testing: bool,
417) -> ModelResult<()> {
418 sqlx::query!(
419 "
420INSERT INTO exam_enrollments (exam_id, user_id, is_teacher_testing)
421VALUES ($1, $2, $3)
422",
423 exam_id,
424 user_id,
425 is_teacher_testing
426 )
427 .execute(conn)
428 .await?;
429 Ok(())
430}
431
432pub async fn verify_exam_submission_can_be_made(
434 conn: &mut PgConnection,
435 exam_id: Uuid,
436 user_id: Uuid,
437) -> ModelResult<bool> {
438 let exam = get(conn, exam_id).await?;
439 let enrollment = get_enrollment(conn, exam_id, user_id)
440 .await?
441 .ok_or_else(|| {
442 model_err!(
443 PreconditionFailed,
444 "User has no enrollment for the exam".to_string()
445 )
446 })?;
447 let student_has_time =
448 Utc::now() <= enrollment.started_at + Duration::minutes(exam.time_minutes.into());
449 let exam_is_ongoing = exam.ends_at.map(|ea| Utc::now() < ea).unwrap_or_default();
450 Ok(student_has_time && exam_is_ongoing)
451}
452
453#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
454
455pub struct ExamEnrollment {
456 pub user_id: Uuid,
457 pub exam_id: Uuid,
458 pub started_at: DateTime<Utc>,
459 pub ended_at: Option<DateTime<Utc>>,
460 pub is_teacher_testing: bool,
461 pub show_exercise_answers: Option<bool>,
462 pub created_at: DateTime<Utc>,
463 pub updated_at: DateTime<Utc>,
464 pub deleted_at: Option<DateTime<Utc>>,
465}
466
467pub async fn get_enrollment(
468 conn: &mut PgConnection,
469 exam_id: Uuid,
470 user_id: Uuid,
471) -> ModelResult<Option<ExamEnrollment>> {
472 let res = sqlx::query_as!(
473 ExamEnrollment,
474 "
475SELECT *
476FROM exam_enrollments
477WHERE exam_id = $1
478 AND user_id = $2
479 AND deleted_at IS NULL
480",
481 exam_id,
482 user_id
483 )
484 .fetch_optional(conn)
485 .await?;
486 Ok(res)
487}
488
489pub async fn get_exam_enrollments_for_users(
490 conn: &mut PgConnection,
491 exam_id: Uuid,
492 user_ids: &[Uuid],
493) -> ModelResult<HashMap<Uuid, ExamEnrollment>> {
494 let enrollments = sqlx::query_as!(
495 ExamEnrollment,
496 "
497SELECT *
498FROM exam_enrollments
499WHERE user_id IN (
500 SELECT UNNEST($1::uuid [])
501 )
502 AND exam_id = $2
503 AND deleted_at IS NULL
504",
505 user_ids,
506 exam_id,
507 )
508 .fetch_all(conn)
509 .await?;
510
511 let mut res: HashMap<Uuid, ExamEnrollment> = HashMap::new();
512 for item in enrollments.into_iter() {
513 res.insert(item.user_id, item);
514 }
515 Ok(res)
516}
517
518pub async fn get_ongoing_exam_enrollments(
519 conn: &mut PgConnection,
520) -> ModelResult<Vec<ExamEnrollment>> {
521 let enrollments = sqlx::query_as!(
522 ExamEnrollment,
523 "
524SELECT *
525FROM exam_enrollments
526WHERE
527 ended_at IS NULL
528 AND deleted_at IS NULL
529"
530 )
531 .fetch_all(conn)
532 .await?;
533 Ok(enrollments)
534}
535
536pub async fn get_exams(conn: &mut PgConnection) -> ModelResult<HashMap<Uuid, OrgExam>> {
537 let exams = sqlx::query_as!(
538 OrgExam,
539 "
540SELECT *
541FROM exams
542WHERE deleted_at IS NULL
543"
544 )
545 .fetch_all(conn)
546 .await?;
547
548 let mut res: HashMap<Uuid, OrgExam> = HashMap::new();
549 for item in exams.into_iter() {
550 res.insert(item.id, item);
551 }
552 Ok(res)
553}
554
555pub async fn update_exam_start_time(
556 conn: &mut PgConnection,
557 exam_id: Uuid,
558 user_id: Uuid,
559 started_at: DateTime<Utc>,
560) -> ModelResult<()> {
561 sqlx::query!(
562 "
563UPDATE exam_enrollments
564SET started_at = $3
565WHERE exam_id = $1
566 AND user_id = $2
567 AND deleted_at IS NULL
568",
569 exam_id,
570 user_id,
571 started_at
572 )
573 .execute(conn)
574 .await?;
575 Ok(())
576}
577
578pub async fn update_exam_ended_at(
579 conn: &mut PgConnection,
580 exam_id: Uuid,
581 user_id: Uuid,
582 ended_at: DateTime<Utc>,
583) -> ModelResult<()> {
584 sqlx::query!(
585 "
586UPDATE exam_enrollments
587SET ended_at = $3
588WHERE exam_id = $1
589 AND user_id = $2
590 AND deleted_at IS NULL
591",
592 exam_id,
593 user_id,
594 ended_at
595 )
596 .execute(conn)
597 .await?;
598 Ok(())
599}
600
601pub async fn update_exam_ended_at_for_users_with_exam_id(
602 conn: &mut PgConnection,
603 exam_id: Uuid,
604 user_ids: &[Uuid],
605 ended_at: DateTime<Utc>,
606) -> ModelResult<()> {
607 sqlx::query!(
608 "
609UPDATE exam_enrollments
610SET ended_at = $3
611WHERE user_id IN (
612 SELECT UNNEST($1::uuid [])
613 )
614 AND exam_id = $2
615 AND deleted_at IS NULL
616",
617 user_ids,
618 exam_id,
619 ended_at
620 )
621 .execute(conn)
622 .await?;
623 Ok(())
624}
625
626pub async fn reset_progress_by_exam_id_and_user_id(
627 conn: &mut PgConnection,
628 exam_id: Uuid,
629 user_id: Uuid,
630) -> ModelResult<()> {
631 let mut tx = conn.begin().await?;
632
633 sqlx::query!(
634 r#"
635UPDATE peer_review_queue_entries
636SET deleted_at = NOW()
637WHERE user_id = $2
638 AND exercise_id IN (
639 SELECT id
640 FROM exercises
641 WHERE exam_id = $1
642 AND deleted_at IS NULL
643 )
644 AND deleted_at IS NULL
645 "#,
646 exam_id,
647 user_id
648 )
649 .execute(&mut *tx)
650 .await?;
651
652 sqlx::query!(
653 r#"
654UPDATE exercise_task_gradings
655SET deleted_at = NOW()
656WHERE exercise_task_submission_id IN (
657 SELECT ets.id
658 FROM exercise_task_submissions ets
659 JOIN exercise_slide_submissions ess
660 ON ess.id = ets.exercise_slide_submission_id
661 WHERE ess.exam_id = $1
662 AND ess.user_id = $2
663 AND ess.deleted_at IS NULL
664 AND ets.deleted_at IS NULL
665 )
666 AND deleted_at IS NULL
667 "#,
668 exam_id,
669 user_id
670 )
671 .execute(&mut *tx)
672 .await?;
673
674 sqlx::query!(
675 r#"
676UPDATE exercise_task_submissions
677SET deleted_at = NOW()
678WHERE exercise_slide_submission_id IN (
679 SELECT id
680 FROM exercise_slide_submissions
681 WHERE exam_id = $1
682 AND user_id = $2
683 AND deleted_at IS NULL
684 )
685 AND deleted_at IS NULL
686 "#,
687 exam_id,
688 user_id
689 )
690 .execute(&mut *tx)
691 .await?;
692
693 sqlx::query!(
694 r#"
695UPDATE teacher_grading_decisions
696SET deleted_at = NOW()
697WHERE user_exercise_state_id IN (
698 SELECT id
699 FROM user_exercise_states
700 WHERE exam_id = $1
701 AND user_id = $2
702 AND deleted_at IS NULL
703 )
704 AND deleted_at IS NULL
705 "#,
706 exam_id,
707 user_id
708 )
709 .execute(&mut *tx)
710 .await?;
711
712 sqlx::query!(
713 r#"
714UPDATE user_exercise_task_states
715SET deleted_at = NOW()
716WHERE user_exercise_slide_state_id IN (
717 SELECT uess.id
718 FROM user_exercise_slide_states uess
719 JOIN user_exercise_states ues ON ues.id = uess.user_exercise_state_id
720 WHERE ues.exam_id = $1
721 AND ues.user_id = $2
722 AND ues.deleted_at IS NULL
723 AND uess.deleted_at IS NULL
724 )
725 AND deleted_at IS NULL
726 "#,
727 exam_id,
728 user_id
729 )
730 .execute(&mut *tx)
731 .await?;
732
733 sqlx::query!(
734 r#"
735UPDATE exercise_slide_submissions
736SET deleted_at = NOW()
737WHERE exam_id = $1
738 AND user_id = $2
739 AND deleted_at IS NULL
740 "#,
741 exam_id,
742 user_id
743 )
744 .execute(&mut *tx)
745 .await?;
746
747 sqlx::query!(
748 r#"
749UPDATE user_exercise_slide_states
750SET deleted_at = NOW()
751WHERE user_exercise_state_id IN (
752 SELECT id
753 FROM user_exercise_states
754 WHERE exam_id = $1
755 AND user_id = $2
756 AND deleted_at IS NULL
757 )
758 AND deleted_at IS NULL
759 "#,
760 exam_id,
761 user_id
762 )
763 .execute(&mut *tx)
764 .await?;
765
766 sqlx::query!(
767 r#"
768UPDATE user_exercise_states
769SET deleted_at = NOW()
770WHERE exam_id = $1
771 AND user_id = $2
772 AND deleted_at IS NULL
773 "#,
774 exam_id,
775 user_id
776 )
777 .execute(&mut *tx)
778 .await?;
779
780 tx.commit().await?;
781 Ok(())
782}
783
784pub async fn update_show_exercise_answers(
785 conn: &mut PgConnection,
786 exam_id: Uuid,
787 user_id: Uuid,
788 show_exercise_answers: bool,
789) -> ModelResult<()> {
790 sqlx::query!(
791 "
792UPDATE exam_enrollments
793SET show_exercise_answers = $3
794WHERE exam_id = $1
795 AND user_id = $2
796 AND deleted_at IS NULL
797",
798 exam_id,
799 user_id,
800 show_exercise_answers
801 )
802 .execute(conn)
803 .await?;
804 Ok(())
805}
806
807pub async fn get_organization_id(conn: &mut PgConnection, exam_id: Uuid) -> ModelResult<Uuid> {
808 let organization_id = sqlx::query!(
809 "
810SELECT *
811FROM exams
812WHERE id = $1
813",
814 exam_id
815 )
816 .fetch_one(conn)
817 .await?
818 .organization_id;
819 Ok(organization_id)
820}
821
822pub async fn get_exam_instructions_data(
823 conn: &mut PgConnection,
824 exam_id: Uuid,
825) -> ModelResult<ExamInstructions> {
826 let exam_instructions_data = sqlx::query_as!(
827 ExamInstructions,
828 "
829SELECT id, instructions
830FROM exams
831WHERE id = $1;
832",
833 exam_id
834 )
835 .fetch_one(conn)
836 .await?;
837 Ok(exam_instructions_data)
838}
839
840pub async fn update_exam_instructions(
841 conn: &mut PgConnection,
842 exam_id: Uuid,
843 instructions_update: ExamInstructionsUpdate,
844) -> ModelResult<ExamInstructions> {
845 let parsed_content: Vec<GutenbergBlock> =
846 serde_json::from_value(instructions_update.instructions)?;
847 let updated_data = sqlx::query_as!(
848 ExamInstructions,
849 "
850 UPDATE exams
851 SET instructions = $1
852 WHERE id = $2
853 RETURNING id,
854 instructions
855 ",
856 serde_json::to_value(parsed_content)?,
857 exam_id
858 )
859 .fetch_one(conn)
860 .await?;
861
862 Ok(updated_data)
863}