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