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