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#[derive(Debug, Serialize, ToSchema)]
8
9pub struct Exam {
10    pub id: Uuid,
11    pub name: String,
12    pub instructions: serde_json::Value,
13    // TODO: page_id is not in the exams table, prevents from using select * with query_as!
14    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    /// Whether or not the exam has already started at the specified timestamp. If no start date for
44    /// exam is defined, returns the provided default instead.
45    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    /// Whether or not the exam has already ended at the specified timestamp. If no end date for exam
53    /// is defined, returns the provided default instead.
54    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
80/// Returns exam identity metadata for a non-deleted exam id.
81pub 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
112/// Returns exam details for a non-deleted exam id.
113pub 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
202/// The exam fields needed to describe an exam without the courses/page join [`get`] does.
203pub 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
432/// Checks whether a submission can be made for the given exam.
433pub 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}