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