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