1use std::collections::HashMap;
2use utoipa::ToSchema;
3
4use crate::{
5 chapters,
6 chapters::DatabaseChapter,
7 exercises,
8 prelude::*,
9 user_details::UserDetail,
10 users::{self, User},
11};
12
13#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
14
15pub struct CourseInstance {
16 pub id: Uuid,
17 pub created_at: DateTime<Utc>,
18 pub updated_at: DateTime<Utc>,
19 pub deleted_at: Option<DateTime<Utc>>,
20 pub course_id: Uuid,
21 pub starts_at: Option<DateTime<Utc>>,
22 pub ends_at: Option<DateTime<Utc>>,
23 pub name: Option<String>,
24 pub description: Option<String>,
25 pub teacher_in_charge_name: String,
26 pub teacher_in_charge_email: String,
27 pub support_email: Option<String>,
28}
29
30impl CourseInstance {
31 pub fn is_open(&self) -> bool {
32 self.starts_at.map(|sa| sa < Utc::now()).unwrap_or_default()
33 }
34}
35
36#[derive(Debug, Deserialize, ToSchema)]
37
38pub struct CourseInstanceForm {
39 pub name: Option<String>,
40 pub description: Option<String>,
41 pub teacher_in_charge_name: String,
42 pub teacher_in_charge_email: String,
43 pub support_email: Option<String>,
44 pub opening_time: Option<DateTime<Utc>>,
45 pub closing_time: Option<DateTime<Utc>>,
46}
47
48#[derive(Debug, Clone, Copy)]
49pub struct NewCourseInstance<'a> {
50 pub course_id: Uuid,
51 pub name: Option<&'a str>,
52 pub description: Option<&'a str>,
53 pub teacher_in_charge_name: &'a str,
54 pub teacher_in_charge_email: &'a str,
55 pub support_email: Option<&'a str>,
56 pub opening_time: Option<DateTime<Utc>>,
57 pub closing_time: Option<DateTime<Utc>>,
58}
59
60pub async fn insert(
61 conn: &mut PgConnection,
62 pkey_policy: PKeyPolicy<Uuid>,
63 new_course_instance: NewCourseInstance<'_>,
64) -> ModelResult<CourseInstance> {
65 let course_instance = sqlx::query_as!(
66 CourseInstance,
67 r#"
68INSERT INTO course_instances (
69 id,
70 course_id,
71 name,
72 description,
73 teacher_in_charge_name,
74 teacher_in_charge_email,
75 support_email
76 )
77VALUES ($1, $2, $3, $4, $5, $6, $7)
78RETURNING *
79"#,
80 pkey_policy.into_uuid(),
81 new_course_instance.course_id,
82 new_course_instance.name,
83 new_course_instance.description,
84 new_course_instance.teacher_in_charge_name,
85 new_course_instance.teacher_in_charge_email,
86 new_course_instance.support_email,
87 )
88 .fetch_one(conn)
89 .await?;
90 Ok(course_instance)
91}
92
93pub async fn get_course_instance(
94 conn: &mut PgConnection,
95 course_instance_id: Uuid,
96) -> ModelResult<CourseInstance> {
97 let course_instance = sqlx::query_as!(
98 CourseInstance,
99 r#"
100SELECT *
101FROM course_instances
102WHERE id = $1
103 AND deleted_at IS NULL;
104 "#,
105 course_instance_id,
106 )
107 .fetch_one(conn)
108 .await?;
109 Ok(course_instance)
110}
111
112pub async fn get_course_instance_with_info(
113 conn: &mut PgConnection,
114 course_instance_id: Uuid,
115) -> ModelResult<CourseInstanceWithCourseInfo> {
116 let course_instance = sqlx::query_as!(
117 CourseInstanceWithCourseInfo,
118 r#"
119SELECT
120 c.id AS "course_id!",
121 c.slug AS "course_slug!",
122 c.name AS "course_name!",
123 c.description AS course_description,
124 ci.id AS course_instance_id,
125 ci.name AS course_instance_name,
126 ci.description AS course_instance_description,
127 o.name AS "organization_name!"
128FROM course_instances AS ci
129 LEFT JOIN courses AS c ON ci.course_id = c.id
130 LEFT JOIN organizations AS o ON o.id = c.organization_id
131WHERE ci.id = $1
132 AND ci.deleted_at IS NULL
133 AND c.deleted_at IS NULL
134 AND o.deleted_at IS NULL
135 "#,
136 course_instance_id,
137 )
138 .fetch_one(conn)
139 .await?;
140 Ok(course_instance)
141}
142
143pub async fn get_default_by_course_id(
144 conn: &mut PgConnection,
145 course_id: Uuid,
146) -> ModelResult<CourseInstance> {
147 let res = sqlx::query_as!(
148 CourseInstance,
149 "
150SELECT *
151FROM course_instances
152WHERE course_id = $1
153 AND name IS NULL
154 AND deleted_at IS NULL
155 ",
156 course_id
157 )
158 .fetch_one(conn)
159 .await?;
160 Ok(res)
161}
162
163pub async fn get_organization_id(
164 conn: &mut PgConnection,
165 course_instance_id: Uuid,
166) -> ModelResult<Uuid> {
167 let res = sqlx::query!(
168 "
169SELECT courses.organization_id
170FROM course_instances
171 JOIN courses ON courses.id = course_instances.course_id
172WHERE course_instances.id = $1
173 AND course_instances.deleted_at IS NULL
174 AND courses.deleted_at IS NULL
175",
176 course_instance_id
177 )
178 .fetch_one(conn)
179 .await?;
180 Ok(res.organization_id)
181}
182
183pub async fn current_course_instance_of_user(
184 conn: &mut PgConnection,
185 user_id: Uuid,
186 course_id: Uuid,
187) -> ModelResult<Option<CourseInstance>> {
188 let course_instance_enrollment = sqlx::query_as!(
189 CourseInstance,
190 r#"
191SELECT i.id,
192 i.created_at,
193 i.updated_at,
194 i.deleted_at,
195 i.course_id,
196 i.starts_at,
197 i.ends_at,
198 i.name,
199 i.description,
200 i.teacher_in_charge_name,
201 i.teacher_in_charge_email,
202 i.support_email
203FROM user_course_settings ucs
204 JOIN course_instances i ON (ucs.current_course_instance_id = i.id)
205WHERE ucs.user_id = $1
206 AND ucs.current_course_id = $2
207 AND ucs.deleted_at IS NULL
208 AND i.deleted_at IS NULL;
209 "#,
210 user_id,
211 course_id,
212 )
213 .fetch_optional(conn)
214 .await?;
215 Ok(course_instance_enrollment)
216}
217
218pub async fn course_instance_by_users_latest_enrollment(
219 conn: &mut PgConnection,
220 user_id: Uuid,
221 course_id: Uuid,
222) -> ModelResult<Option<CourseInstance>> {
223 let course_instance = sqlx::query_as!(
224 CourseInstance,
225 r#"
226SELECT i.id,
227 i.created_at,
228 i.updated_at,
229 i.deleted_at,
230 i.course_id,
231 i.starts_at,
232 i.ends_at,
233 i.name,
234 i.description,
235 i.teacher_in_charge_name,
236 i.teacher_in_charge_email,
237 i.support_email
238FROM course_instances i
239 JOIN course_instance_enrollments ie ON (i.id = ie.course_id)
240WHERE i.course_id = $1
241 AND i.deleted_at IS NULL
242 AND ie.user_id = $2
243 AND ie.deleted_at IS NULL
244ORDER BY ie.created_at DESC;
245 "#,
246 course_id,
247 user_id,
248 )
249 .fetch_optional(conn)
250 .await?;
251 Ok(course_instance)
252}
253
254pub async fn get_all_course_instances(conn: &mut PgConnection) -> ModelResult<Vec<CourseInstance>> {
255 let course_instances = sqlx::query_as!(
256 CourseInstance,
257 r#"
258SELECT *
259FROM course_instances
260WHERE deleted_at IS NULL
261"#
262 )
263 .fetch_all(conn)
264 .await?;
265 Ok(course_instances)
266}
267
268pub async fn get_course_instances_for_course(
269 conn: &mut PgConnection,
270 course_id: Uuid,
271) -> ModelResult<Vec<CourseInstance>> {
272 let course_instances = sqlx::query_as!(
273 CourseInstance,
274 r#"
275SELECT *
276FROM course_instances
277WHERE course_id = $1
278 AND deleted_at IS NULL;
279 "#,
280 course_id,
281 )
282 .fetch_all(conn)
283 .await?;
284 Ok(course_instances)
285}
286
287pub async fn get_course_instance_ids_with_course_id(
288 conn: &mut PgConnection,
289 course_id: Uuid,
290) -> ModelResult<Vec<Uuid>> {
291 let res = sqlx::query!(
292 r#"
293SELECT id
294FROM course_instances
295WHERE course_id = $1
296 AND deleted_at IS NULL;
297 "#,
298 course_id,
299 )
300 .map(|r| r.id)
301 .fetch_all(conn)
302 .await?;
303 Ok(res)
304}
305
306#[derive(Debug, Serialize, ToSchema)]
307
308pub struct ChapterScore {
309 #[serde(flatten)]
310 pub chapter: DatabaseChapter,
311 pub score_given: f32,
312 pub score_total: i32,
313}
314
315#[derive(Debug, Default, Serialize, ToSchema)]
316
317pub struct PointMap(pub HashMap<Uuid, f32>);
318
319#[derive(Debug, Serialize, ToSchema)]
320
321pub struct Points {
322 pub chapter_points: Vec<ChapterScore>,
323 pub users: Vec<UserDetail>,
324 pub user_chapter_points: HashMap<Uuid, PointMap>,
326}
327
328pub async fn get_points(
329 conn: &mut PgConnection,
330 instance_id: Uuid,
331 _pagination: Pagination, ) -> ModelResult<Points> {
333 let mut chapter_point_totals = HashMap::<Uuid, i32>::new();
334 let mut exercise_to_chapter_id = HashMap::new();
335 let course_instance = crate::course_instances::get_course_instance(conn, instance_id).await?;
336 let exercises =
337 exercises::get_exercises_by_course_id(&mut *conn, course_instance.course_id).await?;
338 for exercise in exercises {
339 if let Some(chapter_id) = exercise.chapter_id {
340 let total = chapter_point_totals.entry(chapter_id).or_default();
342 *total += exercise.score_maximum;
343 exercise_to_chapter_id.insert(exercise.id, chapter_id);
344 }
345 }
346
347 let users: HashMap<Uuid, User> =
348 users::get_users_by_course_instance_enrollment(conn, instance_id)
349 .await?
350 .into_iter()
351 .map(|u| (u.id, u))
352 .collect();
353 let mut chapter_points_given = HashMap::<Uuid, f32>::new();
354 let states = sqlx::query!(
355 "
356SELECT user_id,
357 exercise_id,
358 score_given
359FROM user_exercise_states
360WHERE course_id = $1
361 AND deleted_at IS NULL
362ORDER BY user_id ASC
363",
364 course_instance.course_id,
365 )
366 .fetch_all(&mut *conn)
367 .await?;
368 let mut user_chapter_points = HashMap::<Uuid, PointMap>::new();
369 for state in states {
370 let user = match users.get(&state.user_id) {
371 Some(user) => user,
372 None => {
373 tracing::warn!(
374 "user {} has an exercise state but no enrollment",
375 state.user_id
376 );
377 continue;
378 }
379 };
380 if let Some(chapter_id) = exercise_to_chapter_id.get(&state.exercise_id).copied() {
381 let chapter_points = user_chapter_points.entry(user.id).or_default();
382 let user_given = chapter_points.0.entry(chapter_id).or_default();
383 let chapter_given = chapter_points_given.entry(chapter_id).or_default();
384 let score_given = state.score_given.unwrap_or_default();
385 *user_given += score_given;
386 *chapter_given += score_given;
387 }
388 }
389
390 let chapters = chapters::course_instance_chapters(&mut *conn, instance_id).await?;
391 let mut chapter_points: Vec<ChapterScore> = chapters
392 .into_iter()
393 .map(|c| ChapterScore {
394 score_given: chapter_points_given.get(&c.id).copied().unwrap_or_default(),
395 score_total: chapter_point_totals.get(&c.id).copied().unwrap_or_default(),
396 chapter: c,
397 })
398 .collect();
399 chapter_points.sort_by_key(|c| c.chapter.chapter_number);
400
401 let list_of_users = users.into_values().collect::<Vec<_>>();
402 let user_id_to_details =
403 crate::user_details::get_users_details_by_user_id_map(&mut *conn, &list_of_users).await?;
404
405 Ok(Points {
406 chapter_points,
407 users: list_of_users
408 .into_iter()
409 .filter_map(|user| user_id_to_details.get(&user.id).cloned())
410 .collect::<Vec<_>>(),
411 user_chapter_points,
412 })
413}
414
415pub async fn edit(
416 conn: &mut PgConnection,
417 instance_id: Uuid,
418 update: CourseInstanceForm,
419) -> ModelResult<()> {
420 sqlx::query!(
421 "
422UPDATE course_instances
423SET name = $1,
424 description = $2,
425 teacher_in_charge_name = $3,
426 teacher_in_charge_email = $4,
427 support_email = $5,
428 starts_at = $6,
429 ends_at = $7
430WHERE id = $8
431 AND deleted_at IS NULL
432",
433 update.name,
434 update.description,
435 update.teacher_in_charge_name,
436 update.teacher_in_charge_email,
437 update.support_email,
438 update.opening_time,
439 update.closing_time,
440 instance_id
441 )
442 .execute(conn)
443 .await?;
444 Ok(())
445}
446
447pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
448 sqlx::query!(
449 "
450UPDATE course_instances
451SET deleted_at = now()
452WHERE id = $1
453AND deleted_at IS NULL
454",
455 id
456 )
457 .execute(conn)
458 .await?;
459 Ok(())
460}
461
462pub async fn get_course_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
463 let res = sqlx::query!(
464 "
465SELECT course_id
466FROM course_instances
467WHERE id = $1
468 AND deleted_at IS NULL
469",
470 id
471 )
472 .fetch_one(conn)
473 .await?;
474 Ok(res.course_id)
475}
476
477pub async fn is_open(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
478 let res = sqlx::query!(
479 "
480SELECT starts_at,
481 ends_at
482FROM course_instances
483WHERE id = $1
484 AND deleted_at IS NULL
485",
486 id
487 )
488 .fetch_one(conn)
489 .await?;
490 let has_started = match res.starts_at {
491 Some(starts_at) => starts_at <= Utc::now(),
492 None => true,
493 };
494 let has_ended = match res.ends_at {
495 Some(ends_at) => ends_at <= Utc::now(),
496 None => false,
497 };
498 let is_open = has_started && !has_ended;
499 Ok(is_open)
500}
501
502pub async fn get_by_ids(
503 conn: &mut PgConnection,
504 course_instance_ids: &[Uuid],
505) -> ModelResult<Vec<CourseInstance>> {
506 let course_instances = sqlx::query_as!(
507 CourseInstance,
508 r#"
509SELECT *
510FROM course_instances
511WHERE id IN (SELECT * FROM UNNEST($1::uuid[]))
512 AND deleted_at IS NULL
513 "#,
514 course_instance_ids
515 )
516 .fetch_all(conn)
517 .await?;
518 Ok(course_instances)
519}
520
521pub struct CourseInstanceWithCourseInfo {
522 pub course_id: Uuid,
523 pub course_slug: String,
524 pub course_name: String,
525 pub course_description: Option<String>,
526 pub course_instance_id: Uuid,
527 pub course_instance_name: Option<String>,
528 pub course_instance_description: Option<String>,
529 pub organization_name: String,
530}
531
532pub async fn get_enrolled_course_instances_for_user(
533 conn: &mut PgConnection,
534 user_id: Uuid,
535) -> ModelResult<Vec<CourseInstanceWithCourseInfo>> {
536 let course_instances = sqlx::query_as!(
537 CourseInstanceWithCourseInfo,
538 r#"
539SELECT
540 c.id AS "course_id!",
541 c.slug AS "course_slug!",
542 c.name AS "course_name!",
543 c.description AS course_description,
544 ci.id AS course_instance_id,
545 ci.name AS course_instance_name,
546 ci.description AS course_instance_description,
547 o.name AS "organization_name!"
548FROM course_instances AS ci
549 JOIN course_instance_enrollments AS cie ON ci.id = cie.course_instance_id
550 LEFT JOIN courses AS c ON ci.course_id = c.id
551 LEFT JOIN organizations AS o ON o.id = c.organization_id
552WHERE cie.user_id = $1
553 AND ci.deleted_at IS NULL
554 AND cie.deleted_at IS NULL
555 AND c.deleted_at IS NULL
556 AND o.deleted_at IS NULL
557"#,
558 user_id
559 )
560 .fetch_all(conn)
561 .await?;
562 Ok(course_instances)
563}
564
565pub async fn get_enrolled_course_instances_for_user_with_exercise_type(
566 conn: &mut PgConnection,
567 user_id: Uuid,
568 exercise_type: &str,
569) -> ModelResult<Vec<CourseInstanceWithCourseInfo>> {
570 let course_instances = sqlx::query_as!(
571 CourseInstanceWithCourseInfo,
572 r#"
573SELECT DISTINCT ON (ci.id)
574 c.id AS "course_id!",
575 c.slug AS "course_slug!",
576 c.name AS "course_name!",
577 c.description AS course_description,
578 ci.id AS course_instance_id,
579 ci.name AS course_instance_name,
580 ci.description AS course_instance_description,
581 o.name AS "organization_name!"
582FROM course_instances AS ci
583 JOIN course_instance_enrollments AS cie ON ci.id = cie.course_instance_id
584 LEFT JOIN courses AS c ON ci.course_id = c.id
585 LEFT JOIN exercises AS e ON e.course_id = c.id
586 LEFT JOIN exercise_slides AS es ON es.exercise_id = e.id
587 LEFT JOIN exercise_tasks AS et ON et.exercise_slide_id = es.id
588 LEFT JOIN organizations AS o ON o.id = c.organization_id
589WHERE cie.user_id = $1
590 AND et.exercise_type = $2
591 AND ci.deleted_at IS NULL
592 AND cie.deleted_at IS NULL
593 AND c.deleted_at IS NULL
594 AND e.deleted_at IS NULL
595 AND es.deleted_at IS NULL
596 AND et.deleted_at IS NULL
597"#,
598 user_id,
599 exercise_type,
600 )
601 .fetch_all(conn)
602 .await?;
603 Ok(course_instances)
604}
605
606pub async fn reset_progress_on_course_instance_for_user(
608 conn: &mut PgConnection,
609 user_id: Uuid,
610 course_id: Uuid,
611) -> ModelResult<()> {
612 let mut tx = conn.begin().await?;
613 sqlx::query!(
614 "
615UPDATE exercise_slide_submissions
616SET deleted_at = now()
617WHERE user_id = $1
618 AND course_id = $2
619 AND deleted_at IS NULL
620 ",
621 user_id,
622 course_id
623 )
624 .execute(&mut *tx)
625 .await?;
626 sqlx::query!(
627 "
628UPDATE exercise_task_submissions
629SET deleted_at = now()
630WHERE exercise_slide_submission_id IN (
631 SELECT id
632 FROM exercise_slide_submissions
633 WHERE user_id = $1
634 AND course_id = $2
635 )
636 AND deleted_at IS NULL
637",
638 user_id,
639 course_id
640 )
641 .execute(&mut *tx)
642 .await?;
643 sqlx::query!(
644 "
645UPDATE peer_review_queue_entries
646SET deleted_at = now()
647WHERE user_id = $1
648 AND course_id = $2
649 AND deleted_at IS NULL
650",
651 user_id,
652 course_id
653 )
654 .execute(&mut *tx)
655 .await?;
656 sqlx::query!(
657 "
658UPDATE peer_or_self_review_submissions
659SET deleted_at = now()
660WHERE user_id = $1
661 AND course_id = $2
662 AND deleted_at IS NULL
663",
664 user_id,
665 course_id
666 )
667 .execute(&mut *tx)
668 .await?;
669 sqlx::query!(
670 "
671UPDATE peer_or_self_review_question_submissions
672SET deleted_at = now()
673WHERE peer_or_self_review_submission_id IN (
674 SELECT id
675 FROM peer_or_self_review_submissions
676 WHERE user_id = $1
677 AND course_id = $2
678 )
679 AND deleted_at IS NULL
680",
681 user_id,
682 course_id
683 )
684 .execute(&mut *tx)
685 .await?;
686 sqlx::query!(
687 "
688UPDATE exercise_task_gradings
689SET deleted_at = now()
690WHERE exercise_task_submission_id IN (
691 SELECT id
692 FROM exercise_task_submissions
693 WHERE exercise_slide_submission_id IN (
694 SELECT id
695 FROM exercise_slide_submissions
696 WHERE user_id = $1
697 AND course_id = $2
698 )
699 )
700 AND deleted_at IS NULL
701",
702 user_id,
703 course_id
704 )
705 .execute(&mut *tx)
706 .await?;
707
708 sqlx::query!(
709 "
710UPDATE user_exercise_states
711SET deleted_at = now()
712WHERE user_id = $1
713 AND course_id = $2
714 AND deleted_at IS NULL
715",
716 user_id,
717 course_id
718 )
719 .execute(&mut *tx)
720 .await?;
721 sqlx::query!(
722 "
723UPDATE user_exercise_task_states
724SET deleted_at = now()
725WHERE user_exercise_slide_state_id IN (
726 SELECT id
727 FROM user_exercise_slide_states
728 WHERE user_exercise_state_id IN (
729 SELECT id
730 FROM user_exercise_states
731 WHERE user_id = $1
732 AND course_id = $2
733 )
734 )
735 AND deleted_at IS NULL
736",
737 user_id,
738 course_id
739 )
740 .execute(&mut *tx)
741 .await?;
742 sqlx::query!(
743 "
744UPDATE user_exercise_slide_states
745SET deleted_at = now()
746WHERE user_exercise_state_id IN (
747 SELECT id
748 FROM user_exercise_states
749 WHERE user_id = $1
750 AND course_id = $2
751 )
752 AND deleted_at IS NULL
753",
754 user_id,
755 course_id
756 )
757 .execute(&mut *tx)
758 .await?;
759 sqlx::query!(
760 "
761UPDATE teacher_grading_decisions
762SET deleted_at = now()
763WHERE user_exercise_state_id IN (
764 SELECT id
765 FROM user_exercise_states
766 WHERE user_id = $1
767 AND course_id = $2
768 )
769 AND deleted_at IS NULL
770",
771 user_id,
772 course_id
773 )
774 .execute(&mut *tx)
775 .await?;
776 sqlx::query!(
777 "
778UPDATE course_module_completions
779SET deleted_at = now()
780WHERE user_id = $1
781AND course_id = $2
782AND deleted_at IS NULL
783",
784 user_id,
785 course_id
786 )
787 .execute(&mut *tx)
788 .await?;
789 sqlx::query!(
790 "
791UPDATE generated_certificates
792SET deleted_at = NOW()
793WHERE user_id = $1
794 AND certificate_configuration_id IN (
795 SELECT certificate_configuration_id
796 FROM certificate_configuration_to_requirements
797 WHERE course_module_id IN (
798 SELECT id
799 FROM course_modules
800 WHERE course_id = $2
801 )
802 AND deleted_at IS NULL
803 )
804 AND deleted_at IS NULL
805",
806 user_id,
807 course_id
808 )
809 .execute(&mut *tx)
810 .await?;
811 sqlx::query!(
812 "
813UPDATE user_chapter_locking_statuses
814SET deleted_at = now()
815WHERE user_id = $1
816 AND course_id = $2
817 AND deleted_at IS NULL
818",
819 user_id,
820 course_id
821 )
822 .execute(&mut *tx)
823 .await?;
824
825 tx.commit().await?;
826 Ok(())
827}
828
829pub async fn get_course_average_duration(
830 conn: &mut PgConnection,
831 course_id: Uuid,
832) -> ModelResult<Option<i64>> {
833 let res = sqlx::query!(
834 "
835SELECT AVG(
836 EXTRACT(
837 EPOCH
838 FROM cmc.completion_date - ce.created_at
839 )
840 )::int8 AS average_duration_seconds
841FROM course_instance_enrollments ce
842 JOIN course_module_completions cmc ON (
843 cmc.course_id = ce.course_id
844 AND cmc.user_id = ce.user_id
845 )
846WHERE ce.course_id = $1
847 AND ce.deleted_at IS NULL
848 AND cmc.deleted_at IS NULL;
849 ",
850 course_id
851 )
852 .fetch_optional(conn)
853 .await?;
854
855 Ok(res.map(|r| r.average_duration_seconds).unwrap_or_default())
856}
857
858pub async fn get_student_duration(
859 conn: &mut PgConnection,
860 user_id: Uuid,
861 course_id: Uuid,
862) -> ModelResult<Option<i64>> {
863 let res = sqlx::query!(
864 "
865SELECT COALESCE(
866 EXTRACT(
867 EPOCH
868 FROM cmc.completion_date - ce.created_at
869 )::int8,
870 0
871 ) AS student_duration_seconds
872FROM course_instance_enrollments ce
873 JOIN course_module_completions cmc ON (
874 cmc.course_id = ce.course_id
875 AND cmc.user_id = ce.user_id
876 )
877WHERE ce.course_id = $1
878 AND ce.user_id = $2
879 AND ce.deleted_at IS NULL
880 AND cmc.deleted_at IS NULL;
881 ",
882 course_id,
883 user_id
884 )
885 .fetch_optional(conn)
886 .await?;
887
888 Ok(res.map(|r| r.student_duration_seconds).unwrap_or_default())
889}
890
891#[cfg(test)]
892mod test {
893 use super::*;
894 use crate::{
895 course_instance_enrollments::NewCourseInstanceEnrollment, exercise_tasks::NewExerciseTask,
896 test_helper::*,
897 };
898
899 #[tokio::test]
900 async fn allows_only_one_instance_per_course_without_name() {
901 insert_data!(:tx, :user, :org, course: course_id);
902
903 let mut tx1 = tx.begin().await;
904 let mut instance = NewCourseInstance {
906 course_id,
907 name: None,
908 description: None,
909 teacher_in_charge_name: "teacher",
910 teacher_in_charge_email: "teacher@example.com",
911 support_email: None,
912 opening_time: None,
913 closing_time: None,
914 };
915 insert(tx1.as_mut(), PKeyPolicy::Generate, instance)
916 .await
917 .unwrap_err();
918 tx1.rollback().await;
919
920 let mut tx2 = tx.begin().await;
921 instance.name = Some("name");
923 insert(tx2.as_mut(), PKeyPolicy::Generate, instance)
924 .await
925 .unwrap();
926 }
927
928 #[tokio::test]
929 async fn gets_enrolled_course_instances_for_user_with_exercise_type() {
930 insert_data!(:tx, user:user_id, :org, course:course_id, :instance, course_module:_course_module_id, chapter:chapter_id, page:page_id, :exercise, slide:exercise_slide_id);
931
932 crate::course_instance_enrollments::insert_enrollment_and_set_as_current(
934 tx.as_mut(),
935 NewCourseInstanceEnrollment {
936 course_id,
937 user_id,
938 course_instance_id: instance.id,
939 },
940 )
941 .await
942 .unwrap();
943 let course_instances =
944 get_enrolled_course_instances_for_user_with_exercise_type(tx.as_mut(), user_id, "tmc")
945 .await
946 .unwrap();
947 assert!(
948 course_instances.is_empty(),
949 "user should not be enrolled on any course with tmc exercises"
950 );
951
952 crate::exercise_tasks::insert(
954 tx.as_mut(),
955 PKeyPolicy::Generate,
956 NewExerciseTask {
957 assignment: Vec::new(),
958 exercise_slide_id,
959 exercise_type: "tmc".to_string(),
960 model_solution_spec: None,
961 private_spec: None,
962 public_spec: None,
963 order_number: 1,
964 },
965 )
966 .await
967 .unwrap();
968 let course_instances =
969 get_enrolled_course_instances_for_user_with_exercise_type(tx.as_mut(), user_id, "tmc")
970 .await
971 .unwrap();
972 assert_eq!(
973 course_instances.len(),
974 1,
975 "user should be enrolled on one course with tmc exercises"
976 );
977 tx.rollback().await;
978 }
979
980 #[tokio::test]
981 async fn gets_course_average_duration_with_empty_database() {
982 insert_data!(:tx, :user, :org, :course);
983 let duration = get_course_average_duration(tx.as_mut(), course)
984 .await
985 .unwrap();
986 assert!(duration.is_none())
987 }
988}