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 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
81/// Returns exam identity metadata for a non-deleted exam id.
82pub 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
113/// Returns exam details for a non-deleted exam id.
114pub 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
397/// Checks whether a submission can be made for the given exam.
398pub 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}