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