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