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 JOIN courses AS c ON ci.course_id = c.id
130 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_types(
568 conn: &mut PgConnection,
569 user_id: Uuid,
570 exercise_types: &[String],
571) -> ModelResult<Vec<CourseInstanceWithCourseInfo>> {
572 let course_instances = sqlx::query_as!(
573 CourseInstanceWithCourseInfo,
574 r#"
575SELECT DISTINCT ON (ci.id)
576 c.id AS "course_id!",
577 c.slug AS "course_slug!",
578 c.name AS "course_name!",
579 c.description AS course_description,
580 ci.id AS course_instance_id,
581 ci.name AS course_instance_name,
582 ci.description AS course_instance_description,
583 o.name AS "organization_name!"
584FROM course_instances AS ci
585 JOIN course_instance_enrollments AS cie ON ci.id = cie.course_instance_id
586 LEFT JOIN courses AS c ON ci.course_id = c.id
587 LEFT JOIN exercises AS e ON e.course_id = c.id
588 LEFT JOIN exercise_slides AS es ON es.exercise_id = e.id
589 LEFT JOIN exercise_tasks AS et ON et.exercise_slide_id = es.id
590 LEFT JOIN organizations AS o ON o.id = c.organization_id
591WHERE cie.user_id = $1
592 AND et.exercise_type = ANY($2)
593 AND ci.deleted_at IS NULL
594 AND cie.deleted_at IS NULL
595 AND c.deleted_at IS NULL
596 AND o.deleted_at IS NULL
597 AND e.deleted_at IS NULL
598 AND es.deleted_at IS NULL
599 AND et.deleted_at IS NULL
600"#,
601 user_id,
602 exercise_types,
603 )
604 .fetch_all(conn)
605 .await?;
606 Ok(course_instances)
607}
608
609pub async fn reset_progress_on_course_instance_for_user(
611 conn: &mut PgConnection,
612 user_id: Uuid,
613 course_id: Uuid,
614) -> ModelResult<()> {
615 let mut tx = conn.begin().await?;
616 sqlx::query!(
617 "
618UPDATE exercise_slide_submissions
619SET deleted_at = now()
620WHERE user_id = $1
621 AND course_id = $2
622 AND deleted_at IS NULL
623 ",
624 user_id,
625 course_id
626 )
627 .execute(&mut *tx)
628 .await?;
629 sqlx::query!(
630 "
631UPDATE exercise_task_submissions
632SET deleted_at = now()
633WHERE exercise_slide_submission_id IN (
634 SELECT id
635 FROM exercise_slide_submissions
636 WHERE user_id = $1
637 AND course_id = $2
638 )
639 AND deleted_at IS NULL
640",
641 user_id,
642 course_id
643 )
644 .execute(&mut *tx)
645 .await?;
646 sqlx::query!(
647 "
648UPDATE peer_review_queue_entries
649SET deleted_at = now()
650WHERE user_id = $1
651 AND course_id = $2
652 AND deleted_at IS NULL
653",
654 user_id,
655 course_id
656 )
657 .execute(&mut *tx)
658 .await?;
659 sqlx::query!(
660 "
661UPDATE peer_or_self_review_submissions
662SET deleted_at = now()
663WHERE user_id = $1
664 AND course_id = $2
665 AND deleted_at IS NULL
666",
667 user_id,
668 course_id
669 )
670 .execute(&mut *tx)
671 .await?;
672 sqlx::query!(
673 "
674UPDATE peer_or_self_review_question_submissions
675SET deleted_at = now()
676WHERE peer_or_self_review_submission_id IN (
677 SELECT id
678 FROM peer_or_self_review_submissions
679 WHERE user_id = $1
680 AND course_id = $2
681 )
682 AND deleted_at IS NULL
683",
684 user_id,
685 course_id
686 )
687 .execute(&mut *tx)
688 .await?;
689 sqlx::query!(
690 "
691UPDATE exercise_task_gradings
692SET deleted_at = now()
693WHERE exercise_task_submission_id IN (
694 SELECT id
695 FROM exercise_task_submissions
696 WHERE exercise_slide_submission_id IN (
697 SELECT id
698 FROM exercise_slide_submissions
699 WHERE user_id = $1
700 AND course_id = $2
701 )
702 )
703 AND deleted_at IS NULL
704",
705 user_id,
706 course_id
707 )
708 .execute(&mut *tx)
709 .await?;
710
711 sqlx::query!(
712 "
713UPDATE user_exercise_states
714SET deleted_at = now()
715WHERE user_id = $1
716 AND course_id = $2
717 AND deleted_at IS NULL
718",
719 user_id,
720 course_id
721 )
722 .execute(&mut *tx)
723 .await?;
724 sqlx::query!(
725 "
726UPDATE user_exercise_task_states
727SET deleted_at = now()
728WHERE user_exercise_slide_state_id IN (
729 SELECT id
730 FROM user_exercise_slide_states
731 WHERE user_exercise_state_id IN (
732 SELECT id
733 FROM user_exercise_states
734 WHERE user_id = $1
735 AND course_id = $2
736 )
737 )
738 AND deleted_at IS NULL
739",
740 user_id,
741 course_id
742 )
743 .execute(&mut *tx)
744 .await?;
745 sqlx::query!(
746 "
747UPDATE user_exercise_slide_states
748SET deleted_at = now()
749WHERE user_exercise_state_id IN (
750 SELECT id
751 FROM user_exercise_states
752 WHERE user_id = $1
753 AND course_id = $2
754 )
755 AND deleted_at IS NULL
756",
757 user_id,
758 course_id
759 )
760 .execute(&mut *tx)
761 .await?;
762 sqlx::query!(
763 "
764UPDATE teacher_grading_decisions
765SET deleted_at = now()
766WHERE user_exercise_state_id IN (
767 SELECT id
768 FROM user_exercise_states
769 WHERE user_id = $1
770 AND course_id = $2
771 )
772 AND deleted_at IS NULL
773",
774 user_id,
775 course_id
776 )
777 .execute(&mut *tx)
778 .await?;
779 sqlx::query!(
780 "
781UPDATE course_module_completions
782SET deleted_at = now()
783WHERE user_id = $1
784AND course_id = $2
785AND deleted_at IS NULL
786",
787 user_id,
788 course_id
789 )
790 .execute(&mut *tx)
791 .await?;
792 sqlx::query!(
793 "
794UPDATE generated_certificates
795SET deleted_at = NOW()
796WHERE user_id = $1
797 AND certificate_configuration_id IN (
798 SELECT certificate_configuration_id
799 FROM certificate_configuration_to_requirements
800 WHERE course_module_id IN (
801 SELECT id
802 FROM course_modules
803 WHERE course_id = $2
804 )
805 AND deleted_at IS NULL
806 )
807 AND deleted_at IS NULL
808",
809 user_id,
810 course_id
811 )
812 .execute(&mut *tx)
813 .await?;
814 sqlx::query!(
815 "
816UPDATE user_chapter_locking_statuses
817SET deleted_at = now()
818WHERE user_id = $1
819 AND course_id = $2
820 AND deleted_at IS NULL
821",
822 user_id,
823 course_id
824 )
825 .execute(&mut *tx)
826 .await?;
827
828 tx.commit().await?;
829 Ok(())
830}
831
832pub async fn get_course_average_duration(
833 conn: &mut PgConnection,
834 course_id: Uuid,
835) -> ModelResult<Option<i64>> {
836 let res = sqlx::query!(
837 "
838SELECT AVG(
839 EXTRACT(
840 EPOCH
841 FROM cmc.completion_date - ce.created_at
842 )
843 )::int8 AS average_duration_seconds
844FROM course_instance_enrollments ce
845 JOIN course_module_completions cmc ON (
846 cmc.course_id = ce.course_id
847 AND cmc.user_id = ce.user_id
848 )
849WHERE ce.course_id = $1
850 AND ce.deleted_at IS NULL
851 AND cmc.deleted_at IS NULL;
852 ",
853 course_id
854 )
855 .fetch_optional(conn)
856 .await?;
857
858 Ok(res.map(|r| r.average_duration_seconds).unwrap_or_default())
859}
860
861pub async fn get_student_duration(
862 conn: &mut PgConnection,
863 user_id: Uuid,
864 course_id: Uuid,
865) -> ModelResult<Option<i64>> {
866 let res = sqlx::query!(
867 "
868SELECT COALESCE(
869 EXTRACT(
870 EPOCH
871 FROM cmc.completion_date - ce.created_at
872 )::int8,
873 0
874 ) AS student_duration_seconds
875FROM course_instance_enrollments ce
876 JOIN course_module_completions cmc ON (
877 cmc.course_id = ce.course_id
878 AND cmc.user_id = ce.user_id
879 )
880WHERE ce.course_id = $1
881 AND ce.user_id = $2
882 AND ce.deleted_at IS NULL
883 AND cmc.deleted_at IS NULL;
884 ",
885 course_id,
886 user_id
887 )
888 .fetch_optional(conn)
889 .await?;
890
891 Ok(res.map(|r| r.student_duration_seconds).unwrap_or_default())
892}
893
894#[cfg(test)]
895mod test {
896 use super::*;
897 use crate::{
898 course_instance_enrollments::NewCourseInstanceEnrollment, exercise_tasks::NewExerciseTask,
899 test_helper::*,
900 };
901
902 #[tokio::test]
903 async fn allows_only_one_instance_per_course_without_name() {
904 insert_data!(:tx, :user, :org, course: course_id);
905
906 let mut tx1 = tx.begin().await;
907 let mut instance = NewCourseInstance {
909 course_id,
910 name: None,
911 description: None,
912 teacher_in_charge_name: "teacher",
913 teacher_in_charge_email: "teacher@example.com",
914 support_email: None,
915 opening_time: None,
916 closing_time: None,
917 };
918 insert(tx1.as_mut(), PKeyPolicy::Generate, instance)
919 .await
920 .unwrap_err();
921 tx1.rollback().await;
922
923 let mut tx2 = tx.begin().await;
924 instance.name = Some("name");
926 insert(tx2.as_mut(), PKeyPolicy::Generate, instance)
927 .await
928 .unwrap();
929 }
930
931 #[tokio::test]
932 async fn gets_enrolled_course_instances_for_user_with_exercise_types() {
933 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);
934
935 crate::course_instance_enrollments::insert_enrollment_and_set_as_current(
937 tx.as_mut(),
938 NewCourseInstanceEnrollment {
939 course_id,
940 user_id,
941 course_instance_id: instance.id,
942 },
943 )
944 .await
945 .unwrap();
946 let tmc = ["tmc".to_string()];
947 let course_instances =
948 get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &tmc)
949 .await
950 .unwrap();
951 assert!(
952 course_instances.is_empty(),
953 "user should not be enrolled on any course with tmc exercises"
954 );
955
956 crate::exercise_tasks::insert(
958 tx.as_mut(),
959 PKeyPolicy::Generate,
960 NewExerciseTask {
961 assignment: Vec::new(),
962 exercise_slide_id,
963 exercise_type: "tmc".to_string(),
964 model_solution_spec: None,
965 private_spec: None,
966 public_spec: None,
967 order_number: 1,
968 },
969 )
970 .await
971 .unwrap();
972 let course_instances =
973 get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &tmc)
974 .await
975 .unwrap();
976 assert_eq!(
977 course_instances.len(),
978 1,
979 "user should be enrolled on one course with tmc exercises"
980 );
981
982 let others = ["quizzes".to_string(), "example-exercise".to_string()];
984 assert!(
985 get_enrolled_course_instances_for_user_with_exercise_types(
986 tx.as_mut(),
987 user_id,
988 &others
989 )
990 .await
991 .unwrap()
992 .is_empty()
993 );
994 assert!(
995 get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &[])
996 .await
997 .unwrap()
998 .is_empty()
999 );
1000
1001 let mixed = ["quizzes".to_string(), "tmc".to_string()];
1003 assert_eq!(
1004 get_enrolled_course_instances_for_user_with_exercise_types(
1005 tx.as_mut(),
1006 user_id,
1007 &mixed
1008 )
1009 .await
1010 .unwrap()
1011 .len(),
1012 1
1013 );
1014 tx.rollback().await;
1015 }
1016
1017 #[tokio::test]
1020 async fn skips_courses_of_a_deleted_organization() {
1021 insert_data!(:tx, user:user_id, org:org_id, course:course_id, :instance, course_module:_course_module_id, chapter:_chapter, page:_page, :exercise, slide:exercise_slide_id);
1022 crate::exercise_tasks::insert(
1023 tx.as_mut(),
1024 PKeyPolicy::Generate,
1025 NewExerciseTask {
1026 assignment: Vec::new(),
1027 exercise_slide_id,
1028 exercise_type: "tmc".to_string(),
1029 model_solution_spec: None,
1030 private_spec: None,
1031 public_spec: None,
1032 order_number: 1,
1033 },
1034 )
1035 .await
1036 .unwrap();
1037 crate::course_instance_enrollments::insert_enrollment_and_set_as_current(
1038 tx.as_mut(),
1039 NewCourseInstanceEnrollment {
1040 course_id,
1041 user_id,
1042 course_instance_id: instance.id,
1043 },
1044 )
1045 .await
1046 .unwrap();
1047
1048 let tmc = ["tmc".to_string()];
1049 assert_eq!(
1050 get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &tmc)
1051 .await
1052 .unwrap()
1053 .len(),
1054 1
1055 );
1056 assert_eq!(
1057 get_enrolled_course_instances_for_user(tx.as_mut(), user_id)
1058 .await
1059 .unwrap()
1060 .len(),
1061 1
1062 );
1063
1064 sqlx::query("UPDATE organizations SET deleted_at = now() WHERE id = $1")
1066 .bind(org_id)
1067 .execute(&mut **tx.as_mut())
1068 .await
1069 .unwrap();
1070
1071 assert!(
1072 get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &tmc)
1073 .await
1074 .unwrap()
1075 .is_empty(),
1076 "a deleted organization's courses must not be listed"
1077 );
1078 assert!(
1079 get_enrolled_course_instances_for_user(tx.as_mut(), user_id)
1080 .await
1081 .unwrap()
1082 .is_empty()
1083 );
1084 tx.rollback().await;
1085 }
1086
1087 #[tokio::test]
1088 async fn gets_course_average_duration_with_empty_database() {
1089 insert_data!(:tx, :user, :org, :course);
1090 let duration = get_course_average_duration(tx.as_mut(), course)
1091 .await
1092 .unwrap();
1093 assert!(duration.is_none())
1094 }
1095}