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 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 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 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
349pub 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}