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