Skip to main content

headless_lms_models/
exams.rs

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    // TODO: page_id is not in the exams table, prevents from using select * with query_as!
15    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    /// Whether or not the exam has already started at the specified timestamp. If no start date for
45    /// exam is defined, returns the provided default instead.
46    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    /// Whether or not the exam has already ended at the specified timestamp. If no end date for exam
54    /// is defined, returns the provided default instead.
55    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
76/// Returns exam identity metadata for a non-deleted exam id.
77pub 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
108/// Returns exam details for a non-deleted exam id.
109pub 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
403/// Checks whether a submission can be made for the given exam.
404pub 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}