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