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