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