Skip to main content

headless_lms_models/
course_instance_enrollments.rs

1use std::collections::HashMap;
2
3use crate::{
4    course_instances::CourseInstance, course_module_completions::CourseModuleCompletion,
5    courses::Course, prelude::*, user_course_settings::UserCourseSettings,
6};
7use utoipa::ToSchema;
8
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
10
11pub struct CourseInstanceEnrollment {
12    pub user_id: Uuid,
13    pub course_id: Uuid,
14    pub course_instance_id: Uuid,
15    pub created_at: DateTime<Utc>,
16    pub updated_at: DateTime<Utc>,
17    pub deleted_at: Option<DateTime<Utc>>,
18}
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
21
22pub struct CourseInstanceEnrollmentsInfo {
23    pub course_instance_enrollments: Vec<CourseInstanceEnrollment>,
24    pub course_instances: Vec<CourseInstance>,
25    pub courses: Vec<Course>,
26    pub user_course_settings: Vec<UserCourseSettings>,
27    pub course_module_completions: Vec<CourseModuleCompletion>,
28}
29
30/// One UTC day's exercise-submission count for a module, used for the activity-density violins on the
31/// cross-course timeline. `day` is midnight of the day the submissions fall in.
32#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
33pub struct DailySubmissionCount {
34    pub day: DateTime<Utc>,
35    pub count: i32,
36}
37
38/// Slim module descriptor so the frontend can label per-module completions and show "X of Y modules"
39/// without a separate course-structure fetch. Default (base) module has `name = None`.
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
41pub struct CourseModuleInfo {
42    pub id: Uuid,
43    pub name: Option<String>,
44    pub order_number: i32,
45    /// Earliest exercise submission by this user in this module. No module "start" is stored, so the
46    /// frontend uses this to infer when an additional module was first worked on. `None` if untouched.
47    pub first_submission_at: Option<DateTime<Utc>>,
48    /// Number of non-deleted exercises in this module. Submission density is divided by this so courses
49    /// of very different size stay comparable. `0` if the module has no chapter-bound exercises.
50    pub exercise_count: i32,
51    /// This user's exercise submissions in this module bucketed by UTC day, ascending. Empty if none.
52    pub daily_submissions: Vec<DailySubmissionCount>,
53    /// ECTS credits the module is worth. `None` when the module grants no credits.
54    pub ects_credits: Option<f32>,
55    /// The module's course code in the university's registry, e.g. `BSCS1001`.
56    pub uh_course_code: Option<String>,
57    pub completions_can_be_registered_to_sisu: bool,
58}
59
60#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
61
62pub struct CourseEnrollmentInfo {
63    pub course_id: Uuid,
64    pub course: Course,
65    pub course_instances: Vec<CourseInstance>,
66    pub user_course_settings: Option<UserCourseSettings>,
67    /// All non-deleted modules of the course, ordered by `order_number`.
68    pub course_modules: Vec<CourseModuleInfo>,
69    pub course_module_completions: Vec<CourseModuleCompletion>,
70    pub course_module_completions_needing_review: i32,
71    pub first_enrolled_at: DateTime<Utc>,
72    pub is_current: bool,
73}
74
75#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
76
77pub struct CourseEnrollmentsInfo {
78    pub course_enrollments: Vec<CourseEnrollmentInfo>,
79}
80
81pub async fn insert(
82    conn: &mut PgConnection,
83    user_id: Uuid,
84    course_id: Uuid,
85    course_instance_id: Uuid,
86) -> ModelResult<()> {
87    sqlx::query!(
88        "
89INSERT INTO course_instance_enrollments (user_id, course_id, course_instance_id)
90VALUES ($1, $2, $3)
91",
92        user_id,
93        course_id,
94        course_instance_id,
95    )
96    .execute(conn)
97    .await?;
98    Ok(())
99}
100
101#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
102pub struct NewCourseInstanceEnrollment {
103    pub user_id: Uuid,
104    pub course_id: Uuid,
105    pub course_instance_id: Uuid,
106}
107
108/**
109Inserts enrollment if it doesn't exist yet, or restores a soft-deleted one.
110
111Safe to call concurrently for the same enrollment, e.g. from multiple tabs or parallel requests.
112*/
113pub async fn insert_enrollment_if_it_doesnt_exist(
114    conn: &mut PgConnection,
115    enrollment: NewCourseInstanceEnrollment,
116) -> ModelResult<CourseInstanceEnrollment> {
117    let mut tx = conn.begin().await?;
118    // A conflict target would leave the table's other unique index as a non-arbiter, which raises a
119    // duplicate key error instead of conflicting when two requests insert at the same time.
120    sqlx::query!(
121        "
122INSERT INTO course_instance_enrollments (user_id, course_id, course_instance_id)
123VALUES ($1, $2, $3)
124ON CONFLICT DO NOTHING
125",
126        enrollment.user_id,
127        enrollment.course_id,
128        enrollment.course_instance_id,
129    )
130    .execute(&mut *tx)
131    .await?;
132    let enrollment = sqlx::query_as!(
133        CourseInstanceEnrollment,
134        "
135UPDATE course_instance_enrollments
136SET deleted_at = NULL
137WHERE user_id = $1
138  AND course_id = $2
139  AND course_instance_id = $3
140RETURNING *
141",
142        enrollment.user_id,
143        enrollment.course_id,
144        enrollment.course_instance_id,
145    )
146    .fetch_one(&mut *tx)
147    .await?;
148    tx.commit().await?;
149    Ok(enrollment)
150}
151
152pub async fn insert_enrollment_and_set_as_current(
153    conn: &mut PgConnection,
154    new_enrollment: NewCourseInstanceEnrollment,
155) -> ModelResult<CourseInstanceEnrollment> {
156    let mut tx = conn.begin().await?;
157
158    let enrollment = insert_enrollment_if_it_doesnt_exist(&mut tx, new_enrollment).await?;
159    crate::user_course_settings::upsert_user_course_settings_for_enrollment(&mut tx, &enrollment)
160        .await?;
161    tx.commit().await?;
162
163    Ok(enrollment)
164}
165
166pub async fn get_by_user_and_course_instance_id(
167    conn: &mut PgConnection,
168    user_id: Uuid,
169    course_instance_id: Uuid,
170) -> ModelResult<CourseInstanceEnrollment> {
171    let res = sqlx::query_as!(
172        CourseInstanceEnrollment,
173        "
174SELECT *
175FROM course_instance_enrollments
176WHERE user_id = $1
177  AND course_instance_id = $2
178  AND deleted_at IS NULL
179        ",
180        user_id,
181        course_instance_id
182    )
183    .fetch_one(conn)
184    .await?;
185    Ok(res)
186}
187
188pub async fn get_by_user_id(
189    conn: &mut PgConnection,
190    user_id: Uuid,
191) -> ModelResult<Vec<CourseInstanceEnrollment>> {
192    let res = sqlx::query_as!(
193        CourseInstanceEnrollment,
194        "
195SELECT *
196FROM course_instance_enrollments
197WHERE user_id = $1
198  AND deleted_at IS NULL
199        ",
200        user_id
201    )
202    .fetch_all(conn)
203    .await?;
204    Ok(res)
205}
206
207pub async fn get_by_user_id_and_course_ids(
208    conn: &mut PgConnection,
209    user_id: Uuid,
210    course_ids: &[Uuid],
211) -> ModelResult<Vec<CourseInstanceEnrollment>> {
212    let res = sqlx::query_as!(
213        CourseInstanceEnrollment,
214        "
215SELECT *
216FROM course_instance_enrollments
217WHERE user_id = $1
218  AND course_id = ANY($2)
219  AND deleted_at IS NULL
220        ",
221        user_id,
222        course_ids
223    )
224    .fetch_all(conn)
225    .await?;
226    Ok(res)
227}
228
229pub async fn get_course_instance_enrollments_info_for_user(
230    conn: &mut PgConnection,
231    user_id: Uuid,
232) -> ModelResult<CourseInstanceEnrollmentsInfo> {
233    let course_instance_enrollments = get_by_user_id(conn, user_id).await?;
234
235    let course_instance_ids: Vec<Uuid> = course_instance_enrollments
236        .iter()
237        .map(|e| e.course_instance_id)
238        .collect();
239
240    let course_instances = crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
241
242    let course_ids: Vec<Uuid> = course_instances.iter().map(|e| e.course_id).collect();
243
244    let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
245
246    let course_module_completions =
247        crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
248
249    // Returns all user course settings because there is always an enrollment for a current course instance (enforced by a database constraint), and all of those are in the course_ids list
250    let user_course_settings =
251        crate::user_course_settings::get_all_by_user_and_multiple_current_courses(
252            conn,
253            &course_ids,
254            user_id,
255        )
256        .await?;
257
258    Ok(CourseInstanceEnrollmentsInfo {
259        course_instance_enrollments,
260        course_instances,
261        courses,
262        user_course_settings,
263        course_module_completions,
264    })
265}
266
267struct CourseEnrollmentRow {
268    course_id: Uuid,
269    first_enrolled_at: Option<DateTime<Utc>>,
270}
271
272/// Returns one entry per course the user is enrolled in, with aggregated data.
273///
274/// Enrollments whose course has been soft-deleted are left out.
275pub async fn get_course_enrollments_info_for_user(
276    conn: &mut PgConnection,
277    user_id: Uuid,
278) -> ModelResult<CourseEnrollmentsInfo> {
279    let rows = sqlx::query_as!(
280        CourseEnrollmentRow,
281        "
282SELECT course_id, MIN(created_at) AS first_enrolled_at
283FROM course_instance_enrollments
284WHERE user_id = $1 AND deleted_at IS NULL
285GROUP BY course_id
286ORDER BY first_enrolled_at
287        ",
288        user_id
289    )
290    .fetch_all(&mut *conn)
291    .await?;
292
293    let course_ids: Vec<Uuid> = rows.iter().map(|r| r.course_id).collect();
294
295    // Earliest submission per module for this user; module ids are globally unique, so grouping by
296    // module alone is enough. Used to infer when an additional (non-base) module was first worked on.
297    // Restricts to non-deleted exercises/chapters like the exercise-count and daily-submission queries
298    // below, so all three agree on which content is in scope (soft-deleted content stays hidden).
299    let module_first_submission_rows = sqlx::query!(
300        r#"
301SELECT c.course_module_id AS "course_module_id!",
302       MIN(ess.created_at) AS "first_submission_at!"
303FROM exercise_slide_submissions ess
304JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
305JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
306WHERE ess.user_id = $1 AND ess.course_id = ANY($2) AND ess.deleted_at IS NULL
307GROUP BY c.course_module_id
308        "#,
309        user_id,
310        &course_ids
311    )
312    .fetch_all(&mut *conn)
313    .await?;
314    let first_submission_by_module: HashMap<Uuid, DateTime<Utc>> = module_first_submission_rows
315        .into_iter()
316        .map(|r| (r.course_module_id, r.first_submission_at))
317        .collect();
318
319    // Exercise count per module, to normalize submission density (submissions per exercise) so courses
320    // of very different size are comparable. Exercises map to a module via their chapter; exercises with
321    // no chapter (e.g. exams) are not counted.
322    let module_exercise_count_rows = sqlx::query!(
323        r#"
324SELECT c.course_module_id AS "course_module_id!",
325       COUNT(*) AS "count!"
326FROM exercises e
327JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
328WHERE e.course_id = ANY($1) AND e.deleted_at IS NULL
329GROUP BY c.course_module_id
330        "#,
331        &course_ids
332    )
333    .fetch_all(&mut *conn)
334    .await?;
335    let exercise_count_by_module: HashMap<Uuid, i64> = module_exercise_count_rows
336        .into_iter()
337        .map(|r| (r.course_module_id, r.count))
338        .collect();
339
340    // This user's submissions per module bucketed by UTC day, for the activity-density violins. Restricts
341    // to the same non-deleted exercises/chapters as the exercise-count query above so the density
342    // numerator and denominator agree; DATE_TRUNC is anchored to UTC (not the session timezone) so the
343    // buckets line up with the frontend's fixed 24h grid.
344    let module_daily_submission_rows = sqlx::query!(
345        r#"
346SELECT c.course_module_id AS "course_module_id!",
347       DATE_TRUNC('day', ess.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS "day!",
348       COUNT(*) AS "count!"
349FROM exercise_slide_submissions ess
350JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
351JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
352WHERE ess.user_id = $1 AND ess.course_id = ANY($2) AND ess.deleted_at IS NULL
353GROUP BY c.course_module_id, DATE_TRUNC('day', ess.created_at AT TIME ZONE 'UTC')
354ORDER BY c.course_module_id, "day!"
355        "#,
356        user_id,
357        &course_ids
358    )
359    .fetch_all(&mut *conn)
360    .await?;
361    let mut daily_submissions_by_module: HashMap<Uuid, Vec<DailySubmissionCount>> = HashMap::new();
362    for r in module_daily_submission_rows {
363        daily_submissions_by_module
364            .entry(r.course_module_id)
365            .or_default()
366            .push(DailySubmissionCount {
367                day: r.day,
368                count: r.count as i32,
369            });
370    }
371
372    let course_instance_enrollments = get_by_user_id(&mut *conn, user_id).await?;
373    let all_course_module_completions =
374        crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
375    let user_course_settings =
376        crate::user_course_settings::get_all_by_user_id(conn, user_id).await?;
377    let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
378    let course_instance_ids: Vec<Uuid> = course_instance_enrollments
379        .iter()
380        .map(|e| e.course_instance_id)
381        .collect();
382    let all_course_instances =
383        crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
384    let all_course_modules = crate::course_modules::get_by_course_ids(conn, &course_ids).await?;
385
386    let mut course_enrollments = Vec::with_capacity(rows.len());
387    for row in rows {
388        // A missing course row means the course was soft-deleted; erroring here would take the
389        // user's other courses down with it.
390        let Some(course) = courses.iter().find(|c| c.id == row.course_id).cloned() else {
391            warn!(
392                user_id = %user_id,
393                course_id = %row.course_id,
394                "Skipping enrollment because its course has been deleted"
395            );
396            continue;
397        };
398        let course_instances: Vec<_> = all_course_instances
399            .iter()
400            .filter(|ci| ci.course_id == row.course_id)
401            .cloned()
402            .collect();
403        let user_course_settings_for_course = user_course_settings
404            .iter()
405            .find(|ucs| ucs.course_language_group_id == course.course_language_group_id)
406            .cloned();
407        let course_modules = all_course_modules
408            .iter()
409            .filter(|m| m.course_id == row.course_id)
410            .map(|m| CourseModuleInfo {
411                id: m.id,
412                name: m.name.clone(),
413                order_number: m.order_number,
414                first_submission_at: first_submission_by_module.get(&m.id).copied(),
415                exercise_count: exercise_count_by_module.get(&m.id).copied().unwrap_or(0) as i32,
416                daily_submissions: daily_submissions_by_module
417                    .get(&m.id)
418                    .cloned()
419                    .unwrap_or_default(),
420                ects_credits: m.ects_credits,
421                uh_course_code: m.uh_course_code.clone(),
422                completions_can_be_registered_to_sisu: m.completions_can_be_registered_to_sisu,
423            })
424            .collect();
425        let course_module_completions = all_course_module_completions
426            .iter()
427            .filter(|cmc| cmc.course_id == row.course_id)
428            .cloned()
429            .collect();
430        let course_module_completions_needing_review = all_course_module_completions
431            .iter()
432            .filter(|cmc| cmc.course_id == row.course_id && cmc.needs_to_be_reviewed)
433            .count() as i32;
434        let is_current = user_course_settings_for_course
435            .as_ref()
436            .map(|ucs| ucs.current_course_id == row.course_id)
437            .unwrap_or(false);
438
439        let first_enrolled_at = row.first_enrolled_at.ok_or_else(|| {
440            crate::ModelError::new(
441                crate::error::ModelErrorType::Generic,
442                "first_enrolled_at missing for grouped enrollment row".to_string(),
443                None,
444            )
445        })?;
446
447        course_enrollments.push(CourseEnrollmentInfo {
448            course_id: row.course_id,
449            course,
450            course_instances,
451            user_course_settings: user_course_settings_for_course,
452            course_modules,
453            course_module_completions,
454            course_module_completions_needing_review,
455            first_enrolled_at,
456            is_current,
457        });
458    }
459
460    Ok(CourseEnrollmentsInfo { course_enrollments })
461}