headless_lms_models/
course_instances.rs

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