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