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  JOIN courses AS c ON ci.course_id = c.id
130  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
565/// Course instances the user is enrolled on that contain at least one task of any of the
566/// given exercise types. An empty `exercise_types` matches nothing.
567pub async fn get_enrolled_course_instances_for_user_with_exercise_types(
568    conn: &mut PgConnection,
569    user_id: Uuid,
570    exercise_types: &[String],
571) -> ModelResult<Vec<CourseInstanceWithCourseInfo>> {
572    let course_instances = sqlx::query_as!(
573        CourseInstanceWithCourseInfo,
574        r#"
575SELECT DISTINCT ON (ci.id)
576    c.id AS "course_id!",
577    c.slug AS "course_slug!",
578    c.name AS "course_name!",
579    c.description AS course_description,
580    ci.id AS course_instance_id,
581    ci.name AS course_instance_name,
582    ci.description AS course_instance_description,
583    o.name AS "organization_name!"
584FROM course_instances AS ci
585  JOIN course_instance_enrollments AS cie ON ci.id = cie.course_instance_id
586  LEFT JOIN courses AS c ON ci.course_id = c.id
587  LEFT JOIN exercises AS e ON e.course_id = c.id
588  LEFT JOIN exercise_slides AS es ON es.exercise_id = e.id
589  LEFT JOIN exercise_tasks AS et ON et.exercise_slide_id = es.id
590  LEFT JOIN organizations AS o ON o.id = c.organization_id
591WHERE cie.user_id = $1
592  AND et.exercise_type = ANY($2)
593  AND ci.deleted_at IS NULL
594  AND cie.deleted_at IS NULL
595  AND c.deleted_at IS NULL
596  AND o.deleted_at IS NULL
597  AND e.deleted_at IS NULL
598  AND es.deleted_at IS NULL
599  AND et.deleted_at IS NULL
600"#,
601        user_id,
602        exercise_types,
603    )
604    .fetch_all(conn)
605    .await?;
606    Ok(course_instances)
607}
608
609/// 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.
610pub async fn reset_progress_on_course_instance_for_user(
611    conn: &mut PgConnection,
612    user_id: Uuid,
613    course_id: Uuid,
614) -> ModelResult<()> {
615    let mut tx = conn.begin().await?;
616    sqlx::query!(
617        "
618UPDATE exercise_slide_submissions
619SET deleted_at = now()
620WHERE user_id = $1
621  AND course_id = $2
622  AND deleted_at IS NULL
623  ",
624        user_id,
625        course_id
626    )
627    .execute(&mut *tx)
628    .await?;
629    sqlx::query!(
630        "
631UPDATE exercise_task_submissions
632SET deleted_at = now()
633WHERE exercise_slide_submission_id IN (
634    SELECT id
635    FROM exercise_slide_submissions
636    WHERE user_id = $1
637      AND course_id = $2
638  )
639  AND deleted_at IS NULL
640",
641        user_id,
642        course_id
643    )
644    .execute(&mut *tx)
645    .await?;
646    sqlx::query!(
647        "
648UPDATE peer_review_queue_entries
649SET deleted_at = now()
650WHERE user_id = $1
651  AND course_id = $2
652  AND deleted_at IS NULL
653",
654        user_id,
655        course_id
656    )
657    .execute(&mut *tx)
658    .await?;
659    sqlx::query!(
660        "
661UPDATE peer_or_self_review_submissions
662SET deleted_at = now()
663WHERE user_id = $1
664  AND course_id = $2
665  AND deleted_at IS NULL
666",
667        user_id,
668        course_id
669    )
670    .execute(&mut *tx)
671    .await?;
672    sqlx::query!(
673        "
674UPDATE peer_or_self_review_question_submissions
675SET deleted_at = now()
676WHERE peer_or_self_review_submission_id IN (
677    SELECT id
678    FROM peer_or_self_review_submissions
679    WHERE user_id = $1
680      AND course_id = $2
681  )
682  AND deleted_at IS NULL
683",
684        user_id,
685        course_id
686    )
687    .execute(&mut *tx)
688    .await?;
689    sqlx::query!(
690        "
691UPDATE exercise_task_gradings
692SET deleted_at = now()
693WHERE exercise_task_submission_id IN (
694    SELECT id
695    FROM exercise_task_submissions
696    WHERE exercise_slide_submission_id IN (
697        SELECT id
698        FROM exercise_slide_submissions
699        WHERE user_id = $1
700          AND course_id = $2
701      )
702  )
703  AND deleted_at IS NULL
704",
705        user_id,
706        course_id
707    )
708    .execute(&mut *tx)
709    .await?;
710
711    sqlx::query!(
712        "
713UPDATE user_exercise_states
714SET deleted_at = now()
715WHERE user_id = $1
716  AND course_id = $2
717  AND deleted_at IS NULL
718",
719        user_id,
720        course_id
721    )
722    .execute(&mut *tx)
723    .await?;
724    sqlx::query!(
725        "
726UPDATE user_exercise_task_states
727SET deleted_at = now()
728WHERE user_exercise_slide_state_id IN (
729    SELECT id
730    FROM user_exercise_slide_states
731    WHERE user_exercise_state_id IN (
732        SELECT id
733        FROM user_exercise_states
734        WHERE user_id = $1
735          AND course_id = $2
736      )
737  )
738  AND deleted_at IS NULL
739",
740        user_id,
741        course_id
742    )
743    .execute(&mut *tx)
744    .await?;
745    sqlx::query!(
746        "
747UPDATE user_exercise_slide_states
748SET deleted_at = now()
749WHERE user_exercise_state_id IN (
750    SELECT id
751    FROM user_exercise_states
752    WHERE user_id = $1
753      AND course_id = $2
754  )
755  AND deleted_at IS NULL
756",
757        user_id,
758        course_id
759    )
760    .execute(&mut *tx)
761    .await?;
762    sqlx::query!(
763        "
764UPDATE teacher_grading_decisions
765SET deleted_at = now()
766WHERE user_exercise_state_id IN (
767    SELECT id
768    FROM user_exercise_states
769    WHERE user_id = $1
770      AND course_id = $2
771  )
772  AND deleted_at IS NULL
773",
774        user_id,
775        course_id
776    )
777    .execute(&mut *tx)
778    .await?;
779    sqlx::query!(
780        "
781UPDATE course_module_completions
782SET deleted_at = now()
783WHERE user_id = $1
784AND course_id = $2
785AND deleted_at IS NULL
786",
787        user_id,
788        course_id
789    )
790    .execute(&mut *tx)
791    .await?;
792    sqlx::query!(
793        "
794UPDATE generated_certificates
795SET deleted_at = NOW()
796WHERE user_id = $1
797  AND certificate_configuration_id IN (
798    SELECT certificate_configuration_id
799    FROM certificate_configuration_to_requirements
800    WHERE course_module_id IN (
801        SELECT id
802        FROM course_modules
803        WHERE course_id = $2
804      )
805      AND deleted_at IS NULL
806  )
807  AND deleted_at IS NULL
808",
809        user_id,
810        course_id
811    )
812    .execute(&mut *tx)
813    .await?;
814    sqlx::query!(
815        "
816UPDATE user_chapter_locking_statuses
817SET deleted_at = now()
818WHERE user_id = $1
819  AND course_id = $2
820  AND deleted_at IS NULL
821",
822        user_id,
823        course_id
824    )
825    .execute(&mut *tx)
826    .await?;
827
828    tx.commit().await?;
829    Ok(())
830}
831
832pub async fn get_course_average_duration(
833    conn: &mut PgConnection,
834    course_id: Uuid,
835) -> ModelResult<Option<i64>> {
836    let res = sqlx::query!(
837        "
838SELECT AVG(
839    EXTRACT(
840      EPOCH
841      FROM cmc.completion_date - ce.created_at
842    )
843  )::int8 AS average_duration_seconds
844FROM course_instance_enrollments ce
845  JOIN course_module_completions cmc ON (
846    cmc.course_id = ce.course_id
847    AND cmc.user_id = ce.user_id
848  )
849WHERE ce.course_id = $1
850  AND ce.deleted_at IS NULL
851  AND cmc.deleted_at IS NULL;
852        ",
853        course_id
854    )
855    .fetch_optional(conn)
856    .await?;
857
858    Ok(res.map(|r| r.average_duration_seconds).unwrap_or_default())
859}
860
861pub async fn get_student_duration(
862    conn: &mut PgConnection,
863    user_id: Uuid,
864    course_id: Uuid,
865) -> ModelResult<Option<i64>> {
866    let res = sqlx::query!(
867        "
868SELECT COALESCE(
869    EXTRACT(
870      EPOCH
871      FROM cmc.completion_date - ce.created_at
872    )::int8,
873    0
874  ) AS student_duration_seconds
875FROM course_instance_enrollments ce
876  JOIN course_module_completions cmc ON (
877    cmc.course_id = ce.course_id
878    AND cmc.user_id = ce.user_id
879  )
880WHERE ce.course_id = $1
881  AND ce.user_id = $2
882  AND ce.deleted_at IS NULL
883  AND cmc.deleted_at IS NULL;
884        ",
885        course_id,
886        user_id
887    )
888    .fetch_optional(conn)
889    .await?;
890
891    Ok(res.map(|r| r.student_duration_seconds).unwrap_or_default())
892}
893
894#[cfg(test)]
895mod test {
896    use super::*;
897    use crate::{
898        course_instance_enrollments::NewCourseInstanceEnrollment, exercise_tasks::NewExerciseTask,
899        test_helper::*,
900    };
901
902    #[tokio::test]
903    async fn allows_only_one_instance_per_course_without_name() {
904        insert_data!(:tx, :user, :org, course: course_id);
905
906        let mut tx1 = tx.begin().await;
907        // courses always have a default instance with no name, so this should fail
908        let mut instance = NewCourseInstance {
909            course_id,
910            name: None,
911            description: None,
912            teacher_in_charge_name: "teacher",
913            teacher_in_charge_email: "teacher@example.com",
914            support_email: None,
915            opening_time: None,
916            closing_time: None,
917        };
918        insert(tx1.as_mut(), PKeyPolicy::Generate, instance)
919            .await
920            .unwrap_err();
921        tx1.rollback().await;
922
923        let mut tx2 = tx.begin().await;
924        // after we give it a name, it should be ok
925        instance.name = Some("name");
926        insert(tx2.as_mut(), PKeyPolicy::Generate, instance)
927            .await
928            .unwrap();
929    }
930
931    #[tokio::test]
932    async fn gets_enrolled_course_instances_for_user_with_exercise_types() {
933        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);
934
935        // enroll user on course
936        crate::course_instance_enrollments::insert_enrollment_and_set_as_current(
937            tx.as_mut(),
938            NewCourseInstanceEnrollment {
939                course_id,
940                user_id,
941                course_instance_id: instance.id,
942            },
943        )
944        .await
945        .unwrap();
946        let tmc = ["tmc".to_string()];
947        let course_instances =
948            get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &tmc)
949                .await
950                .unwrap();
951        assert!(
952            course_instances.is_empty(),
953            "user should not be enrolled on any course with tmc exercises"
954        );
955
956        // insert tmc exercise task
957        crate::exercise_tasks::insert(
958            tx.as_mut(),
959            PKeyPolicy::Generate,
960            NewExerciseTask {
961                assignment: Vec::new(),
962                exercise_slide_id,
963                exercise_type: "tmc".to_string(),
964                model_solution_spec: None,
965                private_spec: None,
966                public_spec: None,
967                order_number: 1,
968            },
969        )
970        .await
971        .unwrap();
972        let course_instances =
973            get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &tmc)
974                .await
975                .unwrap();
976        assert_eq!(
977            course_instances.len(),
978            1,
979            "user should be enrolled on one course with tmc exercises"
980        );
981
982        // A type list the course has no task for must not match, and neither must an empty list.
983        let others = ["quizzes".to_string(), "example-exercise".to_string()];
984        assert!(
985            get_enrolled_course_instances_for_user_with_exercise_types(
986                tx.as_mut(),
987                user_id,
988                &others
989            )
990            .await
991            .unwrap()
992            .is_empty()
993        );
994        assert!(
995            get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &[])
996                .await
997                .unwrap()
998                .is_empty()
999        );
1000
1001        // Any list containing a type the course does have matches.
1002        let mixed = ["quizzes".to_string(), "tmc".to_string()];
1003        assert_eq!(
1004            get_enrolled_course_instances_for_user_with_exercise_types(
1005                tx.as_mut(),
1006                user_id,
1007                &mixed
1008            )
1009            .await
1010            .unwrap()
1011            .len(),
1012            1
1013        );
1014        tx.rollback().await;
1015    }
1016
1017    /// A soft-deleted organization takes its courses out of every listing, or the client API keeps
1018    /// offering courses of an organization the main frontend has already stopped showing.
1019    #[tokio::test]
1020    async fn skips_courses_of_a_deleted_organization() {
1021        insert_data!(:tx, user:user_id, org:org_id, course:course_id, :instance, course_module:_course_module_id, chapter:_chapter, page:_page, :exercise, slide:exercise_slide_id);
1022        crate::exercise_tasks::insert(
1023            tx.as_mut(),
1024            PKeyPolicy::Generate,
1025            NewExerciseTask {
1026                assignment: Vec::new(),
1027                exercise_slide_id,
1028                exercise_type: "tmc".to_string(),
1029                model_solution_spec: None,
1030                private_spec: None,
1031                public_spec: None,
1032                order_number: 1,
1033            },
1034        )
1035        .await
1036        .unwrap();
1037        crate::course_instance_enrollments::insert_enrollment_and_set_as_current(
1038            tx.as_mut(),
1039            NewCourseInstanceEnrollment {
1040                course_id,
1041                user_id,
1042                course_instance_id: instance.id,
1043            },
1044        )
1045        .await
1046        .unwrap();
1047
1048        let tmc = ["tmc".to_string()];
1049        assert_eq!(
1050            get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &tmc)
1051                .await
1052                .unwrap()
1053                .len(),
1054            1
1055        );
1056        assert_eq!(
1057            get_enrolled_course_instances_for_user(tx.as_mut(), user_id)
1058                .await
1059                .unwrap()
1060                .len(),
1061            1
1062        );
1063
1064        // Not a `query!`: `cargo sqlx prepare -- --lib` does not cache test-only queries.
1065        sqlx::query("UPDATE organizations SET deleted_at = now() WHERE id = $1")
1066            .bind(org_id)
1067            .execute(&mut **tx.as_mut())
1068            .await
1069            .unwrap();
1070
1071        assert!(
1072            get_enrolled_course_instances_for_user_with_exercise_types(tx.as_mut(), user_id, &tmc)
1073                .await
1074                .unwrap()
1075                .is_empty(),
1076            "a deleted organization's courses must not be listed"
1077        );
1078        assert!(
1079            get_enrolled_course_instances_for_user(tx.as_mut(), user_id)
1080                .await
1081                .unwrap()
1082                .is_empty()
1083        );
1084        tx.rollback().await;
1085    }
1086
1087    #[tokio::test]
1088    async fn gets_course_average_duration_with_empty_database() {
1089        insert_data!(:tx, :user, :org, :course);
1090        let duration = get_course_average_duration(tx.as_mut(), course)
1091            .await
1092            .unwrap();
1093        assert!(duration.is_none())
1094    }
1095}