Skip to main content

headless_lms_models/
course_instances.rs

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