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