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