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
492",
493        id
494    )
495    .execute(conn)
496    .await?;
497    Ok(())
498}
499
500pub async fn get_course_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Uuid> {
501    let res = sqlx::query!(
502        "
503SELECT course_id
504FROM course_instances
505WHERE id = $1
506",
507        id
508    )
509    .fetch_one(conn)
510    .await?;
511    Ok(res.course_id)
512}
513
514pub async fn is_open(conn: &mut PgConnection, id: Uuid) -> ModelResult<bool> {
515    let res = sqlx::query!(
516        "
517SELECT starts_at,
518  ends_at
519FROM course_instances
520WHERE id = $1
521",
522        id
523    )
524    .fetch_one(conn)
525    .await?;
526    let has_started = match res.starts_at {
527        Some(starts_at) => starts_at <= Utc::now(),
528        None => true,
529    };
530    let has_ended = match res.ends_at {
531        Some(ends_at) => ends_at <= Utc::now(),
532        None => false,
533    };
534    let is_open = has_started && !has_ended;
535    Ok(is_open)
536}
537
538pub async fn get_by_ids(
539    conn: &mut PgConnection,
540    course_instance_ids: &[Uuid],
541) -> ModelResult<Vec<CourseInstance>> {
542    let course_instances = sqlx::query_as!(
543        CourseInstance,
544        r#"
545SELECT *
546FROM course_instances
547WHERE id IN (SELECT * FROM UNNEST($1::uuid[]))
548    "#,
549        course_instance_ids
550    )
551    .fetch_all(conn)
552    .await?;
553    Ok(course_instances)
554}
555
556pub struct CourseInstanceWithCourseInfo {
557    pub course_id: Uuid,
558    pub course_slug: String,
559    pub course_name: String,
560    pub course_description: Option<String>,
561    pub course_instance_id: Uuid,
562    pub course_instance_name: Option<String>,
563    pub course_instance_description: Option<String>,
564    pub organization_name: String,
565}
566
567pub async fn get_enrolled_course_instances_for_user(
568    conn: &mut PgConnection,
569    user_id: Uuid,
570) -> ModelResult<Vec<CourseInstanceWithCourseInfo>> {
571    let course_instances = sqlx::query_as!(
572        CourseInstanceWithCourseInfo,
573        r#"
574SELECT
575    c.id AS course_id,
576    c.slug AS course_slug,
577    c.name AS course_name,
578    c.description AS course_description,
579    ci.id AS course_instance_id,
580    ci.name AS course_instance_name,
581    ci.description AS course_instance_description,
582    o.name AS organization_name
583FROM course_instances AS ci
584  JOIN course_instance_enrollments AS cie ON ci.id = cie.course_instance_id
585  LEFT JOIN courses AS c ON ci.course_id = c.id
586  LEFT JOIN organizations AS o ON o.id = c.organization_id
587WHERE cie.user_id = $1
588  AND ci.deleted_at IS NULL
589  AND cie.deleted_at IS NULL
590  AND c.deleted_at IS NULL
591  AND o.deleted_at IS NULL
592"#,
593        user_id
594    )
595    .fetch_all(conn)
596    .await?;
597    Ok(course_instances)
598}
599
600pub async fn get_enrolled_course_instances_for_user_with_exercise_type(
601    conn: &mut PgConnection,
602    user_id: Uuid,
603    exercise_type: &str,
604) -> ModelResult<Vec<CourseInstanceWithCourseInfo>> {
605    let course_instances = sqlx::query_as!(
606        CourseInstanceWithCourseInfo,
607        r#"
608SELECT DISTINCT ON (ci.id)
609    c.id AS course_id,
610    c.slug AS course_slug,
611    c.name AS course_name,
612    c.description AS course_description,
613    ci.id AS course_instance_id,
614    ci.name AS course_instance_name,
615    ci.description AS course_instance_description,
616    o.name AS organization_name
617FROM course_instances AS ci
618  JOIN course_instance_enrollments AS cie ON ci.id = cie.course_instance_id
619  LEFT JOIN courses AS c ON ci.course_id = c.id
620  LEFT JOIN exercises AS e ON e.course_id = c.id
621  LEFT JOIN exercise_slides AS es ON es.exercise_id = e.id
622  LEFT JOIN exercise_tasks AS et ON et.exercise_slide_id = es.id
623  LEFT JOIN organizations AS o ON o.id = c.organization_id
624WHERE cie.user_id = $1
625  AND et.exercise_type = $2
626  AND ci.deleted_at IS NULL
627  AND cie.deleted_at IS NULL
628  AND c.deleted_at IS NULL
629  AND e.deleted_at IS NULL
630  AND es.deleted_at IS NULL
631  AND et.deleted_at IS NULL
632"#,
633        user_id,
634        exercise_type,
635    )
636    .fetch_all(conn)
637    .await?;
638    Ok(course_instances)
639}
640
641/// 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.
642pub async fn reset_progress_on_course_instance_for_user(
643    conn: &mut PgConnection,
644    user_id: Uuid,
645    course_id: Uuid,
646) -> ModelResult<()> {
647    let mut tx = conn.begin().await?;
648    sqlx::query!(
649        "
650UPDATE exercise_slide_submissions
651SET deleted_at = now()
652WHERE user_id = $1
653  AND course_id = $2
654  AND deleted_at IS NULL
655  ",
656        user_id,
657        course_id
658    )
659    .execute(&mut *tx)
660    .await?;
661    sqlx::query!(
662        "
663UPDATE exercise_task_submissions
664SET deleted_at = now()
665WHERE exercise_slide_submission_id IN (
666    SELECT id
667    FROM exercise_slide_submissions
668    WHERE user_id = $1
669      AND course_id = $2
670  )
671  AND deleted_at IS NULL
672",
673        user_id,
674        course_id
675    )
676    .execute(&mut *tx)
677    .await?;
678    sqlx::query!(
679        "
680UPDATE peer_review_queue_entries
681SET deleted_at = now()
682WHERE user_id = $1
683  AND course_id = $2
684  AND deleted_at IS NULL
685",
686        user_id,
687        course_id
688    )
689    .execute(&mut *tx)
690    .await?;
691    sqlx::query!(
692        "
693UPDATE peer_or_self_review_submissions
694SET deleted_at = now()
695WHERE user_id = $1
696  AND course_id = $2
697  AND deleted_at IS NULL
698",
699        user_id,
700        course_id
701    )
702    .execute(&mut *tx)
703    .await?;
704    sqlx::query!(
705        "
706UPDATE peer_or_self_review_question_submissions
707SET deleted_at = now()
708WHERE peer_or_self_review_submission_id IN (
709    SELECT id
710    FROM peer_or_self_review_submissions
711    WHERE user_id = $1
712      AND course_id = $2
713  )
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 exercise_task_gradings
724SET deleted_at = now()
725WHERE exercise_task_submission_id IN (
726    SELECT id
727    FROM exercise_task_submissions
728    WHERE exercise_slide_submission_id IN (
729        SELECT id
730        FROM exercise_slide_submissions
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
743    sqlx::query!(
744        "
745UPDATE user_exercise_states
746SET deleted_at = now()
747WHERE user_id = $1
748  AND course_id = $2
749  AND deleted_at IS NULL
750",
751        user_id,
752        course_id
753    )
754    .execute(&mut *tx)
755    .await?;
756    sqlx::query!(
757        "
758UPDATE user_exercise_task_states
759SET deleted_at = now()
760WHERE user_exercise_slide_state_id IN (
761    SELECT id
762    FROM user_exercise_slide_states
763    WHERE 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  )
770  AND deleted_at IS NULL
771",
772        user_id,
773        course_id
774    )
775    .execute(&mut *tx)
776    .await?;
777    sqlx::query!(
778        "
779UPDATE user_exercise_slide_states
780SET deleted_at = now()
781WHERE user_exercise_state_id IN (
782    SELECT id
783    FROM user_exercise_states
784    WHERE user_id = $1
785      AND course_id = $2
786  )
787  AND deleted_at IS NULL
788",
789        user_id,
790        course_id
791    )
792    .execute(&mut *tx)
793    .await?;
794    sqlx::query!(
795        "
796UPDATE teacher_grading_decisions
797SET deleted_at = now()
798WHERE user_exercise_state_id IN (
799    SELECT id
800    FROM user_exercise_states
801    WHERE user_id = $1
802      AND course_id = $2
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 course_module_completions
814SET deleted_at = now()
815WHERE user_id = $1
816AND course_id = $2
817AND deleted_at IS NULL
818",
819        user_id,
820        course_id
821    )
822    .execute(&mut *tx)
823    .await?;
824    sqlx::query!(
825        "
826UPDATE generated_certificates
827SET deleted_at = NOW()
828WHERE user_id = $1
829  AND certificate_configuration_id IN (
830    SELECT certificate_configuration_id
831    FROM certificate_configuration_to_requirements
832    WHERE course_module_id IN (
833        SELECT id
834        FROM course_modules
835        WHERE course_id = $2
836      )
837      AND deleted_at IS NULL
838  )
839  AND deleted_at IS NULL
840",
841        user_id,
842        course_id
843    )
844    .execute(&mut *tx)
845    .await?;
846
847    tx.commit().await?;
848    Ok(())
849}
850
851pub async fn get_course_average_duration(
852    conn: &mut PgConnection,
853    course_id: Uuid,
854) -> ModelResult<Option<i64>> {
855    let res = sqlx::query!(
856        "
857SELECT AVG(
858    EXTRACT(
859      EPOCH
860      FROM cmc.completion_date - ce.created_at
861    )
862  )::int8 AS average_duration_seconds
863FROM course_instance_enrollments ce
864  JOIN course_module_completions cmc ON (
865    cmc.course_id = ce.course_id
866    AND cmc.user_id = ce.user_id
867  )
868WHERE ce.course_id = $1
869  AND ce.deleted_at IS NULL
870  AND cmc.deleted_at IS NULL;
871        ",
872        course_id
873    )
874    .fetch_optional(conn)
875    .await?;
876
877    Ok(res.map(|r| r.average_duration_seconds).unwrap_or_default())
878}
879
880pub async fn get_student_duration(
881    conn: &mut PgConnection,
882    user_id: Uuid,
883    course_id: Uuid,
884) -> ModelResult<Option<i64>> {
885    let res = sqlx::query!(
886        "
887SELECT COALESCE(
888    EXTRACT(
889      EPOCH
890      FROM cmc.completion_date - ce.created_at
891    )::int8,
892    0
893  ) AS student_duration_seconds
894FROM course_instance_enrollments ce
895  JOIN course_module_completions cmc ON (
896    cmc.course_id = ce.course_id
897    AND cmc.user_id = ce.user_id
898  )
899WHERE ce.course_id = $1
900  AND ce.user_id = $2
901  AND ce.deleted_at IS NULL
902  AND cmc.deleted_at IS NULL;
903        ",
904        course_id,
905        user_id
906    )
907    .fetch_optional(conn)
908    .await?;
909
910    Ok(res.map(|r| r.student_duration_seconds).unwrap_or_default())
911}
912
913#[cfg(test)]
914mod test {
915    use super::*;
916    use crate::{
917        course_instance_enrollments::NewCourseInstanceEnrollment, exercise_tasks::NewExerciseTask,
918        test_helper::*,
919    };
920
921    #[tokio::test]
922    async fn allows_only_one_instance_per_course_without_name() {
923        insert_data!(:tx, :user, :org, course: course_id);
924
925        let mut tx1 = tx.begin().await;
926        // courses always have a default instance with no name, so this should fail
927        let mut instance = NewCourseInstance {
928            course_id,
929            name: None,
930            description: None,
931            teacher_in_charge_name: "teacher",
932            teacher_in_charge_email: "teacher@example.com",
933            support_email: None,
934            opening_time: None,
935            closing_time: None,
936        };
937        insert(tx1.as_mut(), PKeyPolicy::Generate, instance)
938            .await
939            .unwrap_err();
940        tx1.rollback().await;
941
942        let mut tx2 = tx.begin().await;
943        // after we give it a name, it should be ok
944        instance.name = Some("name");
945        insert(tx2.as_mut(), PKeyPolicy::Generate, instance)
946            .await
947            .unwrap();
948    }
949
950    #[tokio::test]
951    async fn gets_enrolled_course_instances_for_user_with_exercise_type() {
952        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);
953
954        // enroll user on course
955        crate::course_instance_enrollments::insert_enrollment_and_set_as_current(
956            tx.as_mut(),
957            NewCourseInstanceEnrollment {
958                course_id,
959                user_id,
960                course_instance_id: instance.id,
961            },
962        )
963        .await
964        .unwrap();
965        let course_instances =
966            get_enrolled_course_instances_for_user_with_exercise_type(tx.as_mut(), user_id, "tmc")
967                .await
968                .unwrap();
969        assert!(
970            course_instances.is_empty(),
971            "user should not be enrolled on any course with tmc exercises"
972        );
973
974        // insert tmc exercise task
975        crate::exercise_tasks::insert(
976            tx.as_mut(),
977            PKeyPolicy::Generate,
978            NewExerciseTask {
979                assignment: Vec::new(),
980                exercise_slide_id,
981                exercise_type: "tmc".to_string(),
982                model_solution_spec: None,
983                private_spec: None,
984                public_spec: None,
985                order_number: 1,
986            },
987        )
988        .await
989        .unwrap();
990        let course_instances =
991            get_enrolled_course_instances_for_user_with_exercise_type(tx.as_mut(), user_id, "tmc")
992                .await
993                .unwrap();
994        assert_eq!(
995            course_instances.len(),
996            1,
997            "user should be enrolled on one course with tmc exercises"
998        );
999        tx.rollback().await;
1000    }
1001
1002    #[tokio::test]
1003    async fn gets_course_average_duration_with_empty_database() {
1004        insert_data!(:tx, :user, :org, :course);
1005        let duration = get_course_average_duration(tx.as_mut(), course)
1006            .await
1007            .unwrap();
1008        assert!(duration.is_none())
1009    }
1010}