headless_lms_models/
exams.rs

1use chrono::Duration;
2use std::collections::HashMap;
3
4use crate::{courses::Course, prelude::*};
5use headless_lms_utils::document_schema_processor::GutenbergBlock;
6
7#[derive(Debug, Serialize)]
8#[cfg_attr(feature = "ts_rs", derive(TS))]
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
24impl Exam {
25    /// Whether or not the exam has already started at the specified timestamp. If no start date for
26    /// exam is defined, returns the provided default instead.
27    pub fn started_at_or(&self, timestamp: DateTime<Utc>, default: bool) -> bool {
28        match self.starts_at {
29            Some(starts_at) => starts_at <= timestamp,
30            None => default,
31        }
32    }
33
34    /// Whether or not the exam has already ended at the specified timestamp. If no end date for exam
35    /// is defined, returns the provided default instead.
36    pub fn ended_at_or(&self, timestamp: DateTime<Utc>, default: bool) -> bool {
37        match self.ends_at {
38            Some(ends_at) => ends_at < timestamp,
39            None => default,
40        }
41    }
42}
43
44#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
45#[cfg_attr(feature = "ts_rs", derive(TS))]
46pub struct OrgExam {
47    pub id: Uuid,
48    pub name: String,
49    pub instructions: serde_json::Value,
50    pub starts_at: Option<DateTime<Utc>>,
51    pub ends_at: Option<DateTime<Utc>>,
52    pub time_minutes: i32,
53    pub organization_id: Uuid,
54    pub minimum_points_treshold: i32,
55}
56
57pub async fn get(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exam> {
58    let exam = sqlx::query!(
59        "
60SELECT exams.id,
61  exams.name,
62  exams.instructions,
63  pages.id AS page_id,
64  exams.starts_at,
65  exams.ends_at,
66  exams.time_minutes,
67  exams.minimum_points_treshold,
68  exams.language,
69  exams.grade_manually
70FROM exams
71  JOIN pages ON pages.exam_id = exams.id
72WHERE exams.id = $1
73",
74        id
75    )
76    .fetch_one(&mut *conn)
77    .await?;
78
79    let courses = sqlx::query_as!(
80        Course,
81        "
82SELECT id,
83  slug,
84  courses.created_at,
85  courses.updated_at,
86  courses.deleted_at,
87  name,
88  description,
89  organization_id,
90  language_code,
91  copied_from,
92  content_search_language::text,
93  course_language_group_id,
94  is_draft,
95  is_test_mode,
96  base_module_completion_requires_n_submodule_completions,
97  can_add_chatbot,
98  is_unlisted,
99  is_joinable_by_code_only,
100  join_code,
101  ask_marketing_consent,
102  flagged_answers_threshold,
103  flagged_answers_skip_manual_review_and_allow_retry,
104  closed_at,
105  closed_additional_message,
106  closed_course_successor_id,
107  chapter_locking_enabled
108FROM courses
109  JOIN course_exams ON courses.id = course_exams.course_id
110WHERE course_exams.exam_id = $1
111  AND courses.deleted_at IS NULL
112  AND course_exams.deleted_at IS NULL
113",
114        id
115    )
116    .fetch_all(&mut *conn)
117    .await?;
118
119    Ok(Exam {
120        id: exam.id,
121        name: exam.name,
122        instructions: exam.instructions,
123        page_id: exam.page_id,
124        starts_at: exam.starts_at,
125        ends_at: exam.ends_at,
126        time_minutes: exam.time_minutes,
127        courses,
128        minimum_points_treshold: exam.minimum_points_treshold,
129        language: exam.language.unwrap_or("en-US".to_string()),
130        grade_manually: exam.grade_manually,
131    })
132}
133
134#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
135#[cfg_attr(feature = "ts_rs", derive(TS))]
136pub struct CourseExam {
137    pub id: Uuid,
138    pub course_id: Uuid,
139    pub course_name: String,
140    pub name: String,
141}
142
143#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
144#[cfg_attr(feature = "ts_rs", derive(TS))]
145pub struct NewExam {
146    pub name: String,
147    pub starts_at: Option<DateTime<Utc>>,
148    pub ends_at: Option<DateTime<Utc>>,
149    pub time_minutes: i32,
150    pub organization_id: Uuid,
151    pub minimum_points_treshold: i32,
152    pub grade_manually: bool,
153}
154
155#[derive(Debug, Serialize)]
156#[cfg_attr(feature = "ts_rs", derive(TS))]
157pub struct ExamInstructions {
158    pub id: Uuid,
159    pub instructions: serde_json::Value,
160}
161
162#[derive(Debug, Serialize, Deserialize)]
163#[cfg_attr(feature = "ts_rs", derive(TS))]
164pub struct ExamInstructionsUpdate {
165    pub instructions: serde_json::Value,
166}
167
168pub async fn insert(
169    conn: &mut PgConnection,
170    pkey_policy: PKeyPolicy<Uuid>,
171    exam: &NewExam,
172) -> ModelResult<Uuid> {
173    let res = sqlx::query!(
174        "
175INSERT INTO exams (
176    id,
177    name,
178    instructions,
179    starts_at,
180    ends_at,
181    time_minutes,
182    organization_id,
183    minimum_points_treshold,
184    grade_manually
185  )
186VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
187RETURNING id
188        ",
189        pkey_policy.into_uuid(),
190        exam.name,
191        serde_json::Value::Array(vec![]),
192        exam.starts_at,
193        exam.ends_at,
194        exam.time_minutes,
195        exam.organization_id,
196        exam.minimum_points_treshold,
197        exam.grade_manually,
198    )
199    .fetch_one(conn)
200    .await?;
201
202    Ok(res.id)
203}
204
205pub async fn edit(conn: &mut PgConnection, id: Uuid, new_exam: NewExam) -> ModelResult<()> {
206    sqlx::query!(
207        "
208UPDATE exams
209SET name = COALESCE($2, name),
210  starts_at = $3,
211  ends_at = $4,
212  time_minutes = $5,
213  minimum_points_treshold = $6,
214  grade_manually = $7
215WHERE id = $1
216",
217        id,
218        new_exam.name,
219        new_exam.starts_at,
220        new_exam.ends_at,
221        new_exam.time_minutes,
222        new_exam.minimum_points_treshold,
223        new_exam.grade_manually,
224    )
225    .execute(conn)
226    .await?;
227    Ok(())
228}
229
230pub async fn get_exams_for_organization(
231    conn: &mut PgConnection,
232    organization: Uuid,
233) -> ModelResult<Vec<OrgExam>> {
234    let res = sqlx::query_as!(
235        OrgExam,
236        "
237SELECT id,
238  name,
239  instructions,
240  starts_at,
241  ends_at,
242  time_minutes,
243  organization_id,
244  minimum_points_treshold
245FROM exams
246WHERE exams.organization_id = $1
247  AND exams.deleted_at IS NULL
248",
249        organization
250    )
251    .fetch_all(conn)
252    .await?;
253    Ok(res)
254}
255
256pub async fn get_organization_exam_with_exam_id(
257    conn: &mut PgConnection,
258    exam_id: Uuid,
259) -> ModelResult<OrgExam> {
260    let res = sqlx::query_as!(
261        OrgExam,
262        "
263SELECT id,
264  name,
265  instructions,
266  starts_at,
267  ends_at,
268  time_minutes,
269  organization_id,
270  minimum_points_treshold
271FROM exams
272WHERE exams.id = $1
273  AND exams.deleted_at IS NULL
274",
275        exam_id
276    )
277    .fetch_one(conn)
278    .await?;
279    Ok(res)
280}
281
282pub async fn get_course_exams_for_organization(
283    conn: &mut PgConnection,
284    organization: Uuid,
285) -> ModelResult<Vec<CourseExam>> {
286    let res = sqlx::query_as!(
287        CourseExam,
288        "
289SELECT exams.id,
290  courses.id as course_id,
291  courses.name as course_name,
292  exams.name
293FROM exams
294  JOIN course_exams ON course_exams.exam_id = exams.id
295  JOIN courses ON courses.id = course_exams.course_id
296WHERE exams.organization_id = $1
297  AND exams.deleted_at IS NULL
298  AND courses.deleted_at IS NULL
299",
300        organization
301    )
302    .fetch_all(conn)
303    .await?;
304    Ok(res)
305}
306
307pub async fn get_exams_for_course(
308    conn: &mut PgConnection,
309    course: Uuid,
310) -> ModelResult<Vec<CourseExam>> {
311    let res = sqlx::query_as!(
312        CourseExam,
313        "
314SELECT exams.id,
315  courses.id as course_id,
316  courses.name as course_name,
317  exams.name
318FROM exams
319  JOIN course_exams ON course_id = $1
320  JOIN courses ON courses.id = $1
321  AND exams.deleted_at IS NULL
322  AND courses.deleted_at IS NULL
323",
324        course
325    )
326    .fetch_all(conn)
327    .await?;
328    Ok(res)
329}
330
331pub async fn enroll(
332    conn: &mut PgConnection,
333    exam_id: Uuid,
334    user_id: Uuid,
335    is_teacher_testing: bool,
336) -> ModelResult<()> {
337    sqlx::query!(
338        "
339INSERT INTO exam_enrollments (exam_id, user_id, is_teacher_testing)
340VALUES ($1, $2, $3)
341",
342        exam_id,
343        user_id,
344        is_teacher_testing
345    )
346    .execute(conn)
347    .await?;
348    Ok(())
349}
350
351/// Checks whether a submission can be made for the given exam.
352pub async fn verify_exam_submission_can_be_made(
353    conn: &mut PgConnection,
354    exam_id: Uuid,
355    user_id: Uuid,
356) -> ModelResult<bool> {
357    let exam = get(conn, exam_id).await?;
358    let enrollment = get_enrollment(conn, exam_id, user_id)
359        .await?
360        .ok_or_else(|| {
361            ModelError::new(
362                ModelErrorType::PreconditionFailed,
363                "User has no enrollment for the exam".to_string(),
364                None,
365            )
366        })?;
367    let student_has_time =
368        Utc::now() <= enrollment.started_at + Duration::minutes(exam.time_minutes.into());
369    let exam_is_ongoing = exam.ends_at.map(|ea| Utc::now() < ea).unwrap_or_default();
370    Ok(student_has_time && exam_is_ongoing)
371}
372
373#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
374#[cfg_attr(feature = "ts_rs", derive(TS))]
375pub struct ExamEnrollment {
376    pub user_id: Uuid,
377    pub exam_id: Uuid,
378    pub started_at: DateTime<Utc>,
379    pub ended_at: Option<DateTime<Utc>>,
380    pub is_teacher_testing: bool,
381    pub show_exercise_answers: Option<bool>,
382}
383
384pub async fn get_enrollment(
385    conn: &mut PgConnection,
386    exam_id: Uuid,
387    user_id: Uuid,
388) -> ModelResult<Option<ExamEnrollment>> {
389    let res = sqlx::query_as!(
390        ExamEnrollment,
391        "
392SELECT user_id,
393  exam_id,
394  started_at,
395  ended_at,
396  is_teacher_testing,
397  show_exercise_answers
398FROM exam_enrollments
399WHERE exam_id = $1
400  AND user_id = $2
401  AND deleted_at IS NULL
402",
403        exam_id,
404        user_id
405    )
406    .fetch_optional(conn)
407    .await?;
408    Ok(res)
409}
410
411pub async fn get_exam_enrollments_for_users(
412    conn: &mut PgConnection,
413    exam_id: Uuid,
414    user_ids: &[Uuid],
415) -> ModelResult<HashMap<Uuid, ExamEnrollment>> {
416    let enrollments = sqlx::query_as!(
417        ExamEnrollment,
418        "
419SELECT user_id,
420  exam_id,
421  started_at,
422  ended_at,
423  is_teacher_testing,
424  show_exercise_answers
425FROM exam_enrollments
426WHERE user_id IN (
427    SELECT UNNEST($1::uuid [])
428  )
429  AND exam_id = $2
430  AND deleted_at IS NULL
431",
432        user_ids,
433        exam_id,
434    )
435    .fetch_all(conn)
436    .await?;
437
438    let mut res: HashMap<Uuid, ExamEnrollment> = HashMap::new();
439    for item in enrollments.into_iter() {
440        res.insert(item.user_id, item);
441    }
442    Ok(res)
443}
444
445pub async fn get_ongoing_exam_enrollments(
446    conn: &mut PgConnection,
447) -> ModelResult<Vec<ExamEnrollment>> {
448    let enrollments = sqlx::query_as!(
449        ExamEnrollment,
450        "
451SELECT user_id,
452  exam_id,
453  started_at,
454  ended_at,
455  is_teacher_testing,
456  show_exercise_answers
457FROM exam_enrollments
458WHERE
459    ended_at IS NULL
460  AND deleted_at IS NULL
461"
462    )
463    .fetch_all(conn)
464    .await?;
465    Ok(enrollments)
466}
467
468pub async fn get_exams(conn: &mut PgConnection) -> ModelResult<HashMap<Uuid, OrgExam>> {
469    let exams = sqlx::query_as!(
470        OrgExam,
471        "
472SELECT id,
473  name,
474  instructions,
475  starts_at,
476  ends_at,
477  time_minutes,
478  organization_id,
479  minimum_points_treshold
480FROM exams
481WHERE deleted_at IS NULL
482"
483    )
484    .fetch_all(conn)
485    .await?;
486
487    let mut res: HashMap<Uuid, OrgExam> = HashMap::new();
488    for item in exams.into_iter() {
489        res.insert(item.id, item);
490    }
491    Ok(res)
492}
493
494pub async fn update_exam_start_time(
495    conn: &mut PgConnection,
496    exam_id: Uuid,
497    user_id: Uuid,
498    started_at: DateTime<Utc>,
499) -> ModelResult<()> {
500    sqlx::query!(
501        "
502UPDATE exam_enrollments
503SET started_at = $3
504WHERE exam_id = $1
505  AND user_id = $2
506  AND deleted_at IS NULL
507",
508        exam_id,
509        user_id,
510        started_at
511    )
512    .execute(conn)
513    .await?;
514    Ok(())
515}
516
517pub async fn update_exam_ended_at(
518    conn: &mut PgConnection,
519    exam_id: Uuid,
520    user_id: Uuid,
521    ended_at: DateTime<Utc>,
522) -> ModelResult<()> {
523    sqlx::query!(
524        "
525UPDATE exam_enrollments
526SET ended_at = $3
527WHERE exam_id = $1
528  AND user_id = $2
529  AND deleted_at IS NULL
530",
531        exam_id,
532        user_id,
533        ended_at
534    )
535    .execute(conn)
536    .await?;
537    Ok(())
538}
539
540pub async fn update_exam_ended_at_for_users_with_exam_id(
541    conn: &mut PgConnection,
542    exam_id: Uuid,
543    user_ids: &[Uuid],
544    ended_at: DateTime<Utc>,
545) -> ModelResult<()> {
546    sqlx::query!(
547        "
548UPDATE exam_enrollments
549SET ended_at = $3
550WHERE user_id IN (
551    SELECT UNNEST($1::uuid [])
552  )
553  AND exam_id = $2
554  AND deleted_at IS NULL
555",
556        user_ids,
557        exam_id,
558        ended_at
559    )
560    .execute(conn)
561    .await?;
562    Ok(())
563}
564
565pub async fn update_show_exercise_answers(
566    conn: &mut PgConnection,
567    exam_id: Uuid,
568    user_id: Uuid,
569    show_exercise_answers: bool,
570) -> ModelResult<()> {
571    sqlx::query!(
572        "
573UPDATE exam_enrollments
574SET show_exercise_answers = $3
575WHERE exam_id = $1
576  AND user_id = $2
577  AND deleted_at IS NULL
578",
579        exam_id,
580        user_id,
581        show_exercise_answers
582    )
583    .execute(conn)
584    .await?;
585    Ok(())
586}
587
588pub async fn get_organization_id(conn: &mut PgConnection, exam_id: Uuid) -> ModelResult<Uuid> {
589    let organization_id = sqlx::query!(
590        "
591SELECT organization_id
592FROM exams
593WHERE id = $1
594",
595        exam_id
596    )
597    .fetch_one(conn)
598    .await?
599    .organization_id;
600    Ok(organization_id)
601}
602
603pub async fn get_exam_instructions_data(
604    conn: &mut PgConnection,
605    exam_id: Uuid,
606) -> ModelResult<ExamInstructions> {
607    let exam_instructions_data = sqlx::query_as!(
608        ExamInstructions,
609        "
610SELECT id, instructions
611FROM exams
612WHERE id = $1;
613",
614        exam_id
615    )
616    .fetch_one(conn)
617    .await?;
618    Ok(exam_instructions_data)
619}
620
621pub async fn update_exam_instructions(
622    conn: &mut PgConnection,
623    exam_id: Uuid,
624    instructions_update: ExamInstructionsUpdate,
625) -> ModelResult<ExamInstructions> {
626    let parsed_content: Vec<GutenbergBlock> =
627        serde_json::from_value(instructions_update.instructions)?;
628    let updated_data = sqlx::query_as!(
629        ExamInstructions,
630        "
631    UPDATE exams
632    SET instructions = $1
633    WHERE id = $2
634    RETURNING id,
635        instructions
636    ",
637        serde_json::to_value(parsed_content)?,
638        exam_id
639    )
640    .fetch_one(conn)
641    .await?;
642
643    Ok(updated_data)
644}