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/// 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.
566pub async fn reset_progress_on_course_instance_for_user(
567    conn: &mut PgConnection,
568    user_id: Uuid,
569    course_id: Uuid,
570) -> ModelResult<()> {
571    let mut tx = conn.begin().await?;
572    sqlx::query!(
573        "
574UPDATE exercise_slide_submissions
575SET deleted_at = now()
576WHERE user_id = $1
577  AND course_id = $2
578  AND deleted_at IS NULL
579  ",
580        user_id,
581        course_id
582    )
583    .execute(&mut *tx)
584    .await?;
585    sqlx::query!(
586        "
587UPDATE exercise_task_submissions
588SET deleted_at = now()
589WHERE exercise_slide_submission_id IN (
590    SELECT id
591    FROM exercise_slide_submissions
592    WHERE user_id = $1
593      AND course_id = $2
594  )
595  AND deleted_at IS NULL
596",
597        user_id,
598        course_id
599    )
600    .execute(&mut *tx)
601    .await?;
602    sqlx::query!(
603        "
604UPDATE peer_review_queue_entries
605SET deleted_at = now()
606WHERE user_id = $1
607  AND course_id = $2
608  AND deleted_at IS NULL
609",
610        user_id,
611        course_id
612    )
613    .execute(&mut *tx)
614    .await?;
615    sqlx::query!(
616        "
617UPDATE peer_or_self_review_submissions
618SET deleted_at = now()
619WHERE user_id = $1
620  AND course_id = $2
621  AND deleted_at IS NULL
622",
623        user_id,
624        course_id
625    )
626    .execute(&mut *tx)
627    .await?;
628    sqlx::query!(
629        "
630UPDATE peer_or_self_review_question_submissions
631SET deleted_at = now()
632WHERE peer_or_self_review_submission_id IN (
633    SELECT id
634    FROM peer_or_self_review_submissions
635    WHERE user_id = $1
636      AND course_id = $2
637  )
638  AND deleted_at IS NULL
639",
640        user_id,
641        course_id
642    )
643    .execute(&mut *tx)
644    .await?;
645    sqlx::query!(
646        "
647UPDATE exercise_task_gradings
648SET deleted_at = now()
649WHERE exercise_task_submission_id IN (
650    SELECT id
651    FROM exercise_task_submissions
652    WHERE exercise_slide_submission_id IN (
653        SELECT id
654        FROM exercise_slide_submissions
655        WHERE user_id = $1
656          AND course_id = $2
657      )
658  )
659  AND deleted_at IS NULL
660",
661        user_id,
662        course_id
663    )
664    .execute(&mut *tx)
665    .await?;
666
667    sqlx::query!(
668        "
669UPDATE user_exercise_states
670SET deleted_at = now()
671WHERE user_id = $1
672  AND course_id = $2
673  AND deleted_at IS NULL
674",
675        user_id,
676        course_id
677    )
678    .execute(&mut *tx)
679    .await?;
680    sqlx::query!(
681        "
682UPDATE user_exercise_task_states
683SET deleted_at = now()
684WHERE user_exercise_slide_state_id IN (
685    SELECT id
686    FROM user_exercise_slide_states
687    WHERE user_exercise_state_id IN (
688        SELECT id
689        FROM user_exercise_states
690        WHERE user_id = $1
691          AND course_id = $2
692      )
693  )
694  AND deleted_at IS NULL
695",
696        user_id,
697        course_id
698    )
699    .execute(&mut *tx)
700    .await?;
701    sqlx::query!(
702        "
703UPDATE user_exercise_slide_states
704SET deleted_at = now()
705WHERE user_exercise_state_id IN (
706    SELECT id
707    FROM user_exercise_states
708    WHERE user_id = $1
709      AND course_id = $2
710  )
711  AND deleted_at IS NULL
712",
713        user_id,
714        course_id
715    )
716    .execute(&mut *tx)
717    .await?;
718    sqlx::query!(
719        "
720UPDATE teacher_grading_decisions
721SET deleted_at = now()
722WHERE user_exercise_state_id IN (
723    SELECT id
724    FROM user_exercise_states
725    WHERE user_id = $1
726      AND course_id = $2
727  )
728  AND deleted_at IS NULL
729",
730        user_id,
731        course_id
732    )
733    .execute(&mut *tx)
734    .await?;
735    sqlx::query!(
736        "
737UPDATE course_module_completions
738SET deleted_at = now()
739WHERE user_id = $1
740AND course_id = $2
741AND deleted_at IS NULL
742",
743        user_id,
744        course_id
745    )
746    .execute(&mut *tx)
747    .await?;
748    sqlx::query!(
749        "
750UPDATE generated_certificates
751SET deleted_at = NOW()
752WHERE user_id = $1
753  AND certificate_configuration_id IN (
754    SELECT certificate_configuration_id
755    FROM certificate_configuration_to_requirements
756    WHERE course_module_id IN (
757        SELECT id
758        FROM course_modules
759        WHERE course_id = $2
760      )
761      AND deleted_at IS NULL
762  )
763  AND deleted_at IS NULL
764",
765        user_id,
766        course_id
767    )
768    .execute(&mut *tx)
769    .await?;
770    sqlx::query!(
771        "
772UPDATE user_chapter_locking_statuses
773SET deleted_at = now()
774WHERE user_id = $1
775  AND course_id = $2
776  AND deleted_at IS NULL
777",
778        user_id,
779        course_id
780    )
781    .execute(&mut *tx)
782    .await?;
783
784    tx.commit().await?;
785    Ok(())
786}
787
788pub async fn get_course_average_duration(
789    conn: &mut PgConnection,
790    course_id: Uuid,
791) -> ModelResult<Option<i64>> {
792    let res = sqlx::query!(
793        "
794SELECT AVG(
795    EXTRACT(
796      EPOCH
797      FROM cmc.completion_date - ce.created_at
798    )
799  )::int8 AS average_duration_seconds
800FROM course_instance_enrollments ce
801  JOIN course_module_completions cmc ON (
802    cmc.course_id = ce.course_id
803    AND cmc.user_id = ce.user_id
804  )
805WHERE ce.course_id = $1
806  AND ce.deleted_at IS NULL
807  AND cmc.deleted_at IS NULL;
808        ",
809        course_id
810    )
811    .fetch_optional(conn)
812    .await?;
813
814    Ok(res.map(|r| r.average_duration_seconds).unwrap_or_default())
815}
816
817pub async fn get_student_duration(
818    conn: &mut PgConnection,
819    user_id: Uuid,
820    course_id: Uuid,
821) -> ModelResult<Option<i64>> {
822    let res = sqlx::query!(
823        "
824SELECT COALESCE(
825    EXTRACT(
826      EPOCH
827      FROM cmc.completion_date - ce.created_at
828    )::int8,
829    0
830  ) AS student_duration_seconds
831FROM course_instance_enrollments ce
832  JOIN course_module_completions cmc ON (
833    cmc.course_id = ce.course_id
834    AND cmc.user_id = ce.user_id
835  )
836WHERE ce.course_id = $1
837  AND ce.user_id = $2
838  AND ce.deleted_at IS NULL
839  AND cmc.deleted_at IS NULL;
840        ",
841        course_id,
842        user_id
843    )
844    .fetch_optional(conn)
845    .await?;
846
847    Ok(res.map(|r| r.student_duration_seconds).unwrap_or_default())
848}
849
850#[cfg(test)]
851mod test {
852    use super::*;
853    use crate::{
854        course_instance_enrollments::NewCourseInstanceEnrollment, exercise_tasks::NewExerciseTask,
855        test_helper::*,
856    };
857
858    #[tokio::test]
859    async fn allows_only_one_instance_per_course_without_name() {
860        insert_data!(:tx, :user, :org, course: course_id);
861
862        let mut tx1 = tx.begin().await;
863        // courses always have a default instance with no name, so this should fail
864        let mut instance = NewCourseInstance {
865            course_id,
866            name: None,
867            description: None,
868            teacher_in_charge_name: "teacher",
869            teacher_in_charge_email: "teacher@example.com",
870            support_email: None,
871            opening_time: None,
872            closing_time: None,
873        };
874        insert(tx1.as_mut(), PKeyPolicy::Generate, instance)
875            .await
876            .unwrap_err();
877        tx1.rollback().await;
878
879        let mut tx2 = tx.begin().await;
880        // after we give it a name, it should be ok
881        instance.name = Some("name");
882        insert(tx2.as_mut(), PKeyPolicy::Generate, instance)
883            .await
884            .unwrap();
885    }
886
887    /// A soft-deleted organization takes its courses out of every listing, or the client API keeps
888    /// offering courses of an organization the main frontend has already stopped showing.
889    #[tokio::test]
890    async fn skips_courses_of_a_deleted_organization() {
891        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);
892        crate::exercise_tasks::insert(
893            tx.as_mut(),
894            PKeyPolicy::Generate,
895            NewExerciseTask {
896                assignment: Vec::new(),
897                exercise_slide_id,
898                exercise_type: "tmc".to_string(),
899                model_solution_spec: None,
900                private_spec: None,
901                public_spec: None,
902                order_number: 1,
903            },
904        )
905        .await
906        .unwrap();
907        crate::course_instance_enrollments::insert_enrollment_and_set_as_current(
908            tx.as_mut(),
909            NewCourseInstanceEnrollment {
910                course_id,
911                user_id,
912                course_instance_id: instance.id,
913            },
914        )
915        .await
916        .unwrap();
917
918        let tmc = ["tmc".to_string()];
919        assert_eq!(
920            crate::courses::get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &tmc)
921                .await
922                .unwrap()
923                .len(),
924            1
925        );
926        assert_eq!(
927            get_enrolled_course_instances_for_user(tx.as_mut(), user_id)
928                .await
929                .unwrap()
930                .len(),
931            1
932        );
933
934        crate::organizations::soft_delete(tx.as_mut(), org_id)
935            .await
936            .unwrap();
937
938        assert!(
939            crate::courses::get_enrolled_courses_with_exercise_types(tx.as_mut(), user_id, &tmc)
940                .await
941                .unwrap()
942                .is_empty(),
943            "a deleted organization's courses must not be listed"
944        );
945        assert!(
946            get_enrolled_course_instances_for_user(tx.as_mut(), user_id)
947                .await
948                .unwrap()
949                .is_empty()
950        );
951        tx.rollback().await;
952    }
953
954    #[tokio::test]
955    async fn gets_course_average_duration_with_empty_database() {
956        insert_data!(:tx, :user, :org, :course);
957        let duration = get_course_average_duration(tx.as_mut(), course)
958            .await
959            .unwrap();
960        assert!(duration.is_none())
961    }
962}