Skip to main content

headless_lms_models/library/
students_view.rs

1//! Contains helper functions needed for student view
2use crate::chapters::{self, ChapterAvailability, DatabaseChapter, UserChapterProgress};
3use crate::credit_registrations::CreditRegistrationState;
4use crate::library::credit_registration::{StageMatch, StudentFacingCreditRegistrationStatus};
5use crate::prelude::*;
6use crate::user_chapter_locking_statuses::UserChapterLockingStatus;
7use chrono::{DateTime, Utc};
8use utoipa::ToSchema;
9
10/// One row of the paginated student identity list (one row per distinct enrolled user).
11#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
12
13pub struct CourseStudentListRow {
14    pub user_id: Uuid,
15    pub first_name: Option<String>,
16    pub last_name: Option<String>,
17    pub email: Option<String>,
18    /// Names of the non-deleted course instances the user is enrolled in for this course.
19    pub course_instances: Vec<String>,
20    /// Whether the user has any enrollment into a non-deleted instance. Separates the unnamed default
21    /// instance (true) from a since-deleted instance (false) when `course_instances` is empty.
22    pub has_active_instance: bool,
23}
24
25/// A page of the student identity list plus the total number of pages for the current filters.
26#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
27
28pub struct StudentsListPage {
29    pub data: Vec<CourseStudentListRow>,
30    pub total_pages: u32,
31}
32
33/// Escapes the `LIKE`/`ILIKE` metacharacters `\`, `%` and `_` so a search string is matched
34/// literally (used together with `ESCAPE '\'` in the query).
35pub fn escape_like_pattern(input: &str) -> String {
36    input
37        .replace('\\', "\\\\")
38        .replace('%', "\\%")
39        .replace('_', "\\_")
40}
41
42/// Grade filter values accepted by [`get_course_students_page`], beyond a literal numeric grade
43/// string (the sis-0-5 scale, `"0"`..`"5"`).
44pub const GRADE_FILTER_NOT_COMPLETED: &str = "not_completed";
45pub const GRADE_FILTER_PASSED: &str = "passed";
46pub const GRADE_FILTER_FAILED: &str = "failed";
47
48/// Returns a filtered, sorted, paginated page of the course's enrolled users (identity only).
49///
50/// `sort_column` (`last_name` | `first_name` | `email` | `total_points`) and `sort_direction` are
51/// narrowed to fixed literals and bound as parameters, never interpolated from raw input. `search`
52/// matches name/email substrings via the trigram `name_search_helper` / `email_search_helper`
53/// columns, plus an exact user-id match when it parses as a UUID. `course_instance_id` narrows to a
54/// single instance.
55///
56/// `module_id` + `grade` together narrow to students whose *latest* completion of that module matches:
57/// a numeric grade string (sis-0-5 scale), [`GRADE_FILTER_PASSED`]/[`GRADE_FILTER_FAILED`] (the
58/// sis-hyv-hyl scale, i.e. `grade IS NULL`), or [`GRADE_FILTER_NOT_COMPLETED`] (no completion row at
59/// all). A numerically graded module's completions never match `passed`/`failed` -- those only ever
60/// apply to modules that use the pass/fail scale, mirroring how `CompletionsTab` renders the grade
61/// column (a numeric grade takes precedence over passed/failed). `grade` is ignored unless `module_id`
62/// is also set.
63///
64/// `registration_stages` narrows to students holding at least one live credit registration at one of
65/// those stages, within `course_instance_id` and `module_id` where either is given. Empty means no
66/// narrowing.
67#[allow(clippy::too_many_arguments)]
68pub async fn get_course_students_page(
69    conn: &mut PgConnection,
70    course_id: Uuid,
71    pagination: Pagination,
72    search: Option<&str>,
73    sort_column: Option<&str>,
74    sort_direction: Option<&str>,
75    course_instance_id: Option<Uuid>,
76    module_id: Option<Uuid>,
77    grade: Option<&str>,
78    registration_stages: &[StudentFacingCreditRegistrationStatus],
79) -> ModelResult<StudentsListPage> {
80    let stages = StageMatch::of(registration_stages);
81    // Empty/blank search behaves like no search.
82    let search = search.map(str::trim).filter(|s| !s.is_empty());
83    let user_id_exact = search.and_then(|s| Uuid::parse_str(s).ok());
84    // The helper columns are lowercased generated columns, so lowercase the term and escape the LIKE
85    // metacharacters (matched literally via `ESCAPE '\'`). The GiST trigram indexes serve LIKE.
86    let search_pattern = search.map(|s| escape_like_pattern(&s.to_lowercase()));
87    // A `grade` without a `module_id` has nothing to scope it to, so it is dropped rather than
88    // matched against every module.
89    let grade_filter = module_id.and(grade);
90
91    // Both the sort column and the direction are narrowed to a fixed literal here, so the query can
92    // bind them and stay one offline-checked shape instead of being built by string formatting.
93    let sort_column = match sort_column {
94        Some("first_name") => "first_name",
95        Some("email") => "email",
96        Some("total_points") => "total_points",
97        _ => "last_name",
98    };
99    let sort_direction = match sort_direction {
100        Some("desc") | Some("DESC") => "desc",
101        _ => "asc",
102    };
103
104    let total_count = sqlx::query_scalar!(
105        r#"
106SELECT COUNT(*) AS "count!"
107FROM (
108  SELECT u.id
109  FROM course_instance_enrollments cie
110    JOIN users u ON u.id = cie.user_id
111    LEFT JOIN user_details ud ON ud.user_id = u.id
112    LEFT JOIN LATERAL (
113      SELECT cmc.grade, cmc.passed
114      FROM course_module_completions cmc
115      WHERE cmc.user_id = u.id
116        AND cmc.course_id = $1
117        AND cmc.course_module_id = $5
118        AND cmc.deleted_at IS NULL
119      ORDER BY cmc.completion_date DESC
120      LIMIT 1
121    ) gm ON $5::uuid IS NOT NULL
122  WHERE cie.course_id = $1
123    AND cie.deleted_at IS NULL
124    AND u.deleted_at IS NULL
125    AND ($2::uuid IS NULL OR cie.course_instance_id = $2)
126    AND (
127      $3::text IS NULL
128      OR ud.name_search_helper LIKE '%' || $3 || '%' ESCAPE '\'
129      OR ud.email_search_helper LIKE '%' || $3 || '%' ESCAPE '\'
130      OR ($4::uuid IS NOT NULL AND u.id = $4)
131    )
132    AND (
133      $6::text IS NULL
134      OR ($6 = 'not_completed' AND gm.grade IS NULL AND gm.passed IS NULL)
135      OR ($6 = 'passed' AND gm.grade IS NULL AND gm.passed = true)
136      OR ($6 = 'failed' AND gm.grade IS NULL AND gm.passed = false)
137      OR ($6 ~ '^[0-9]+$' AND gm.grade = $6::int)
138    )
139    AND (
140      CARDINALITY($7::credit_registration_state []) = 0
141      OR EXISTS (
142        SELECT 1
143        FROM credit_registrations cr
144          JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
145          JOIN UNNEST(
146              $7::credit_registration_state [],
147              $8::boolean [],
148              $9::boolean [],
149              $10::boolean [],
150              $11::boolean []
151            ) AS stage(
152              state,
153              completion_eligible,
154              has_verified_student_number,
155              course_code_allowed,
156              enrolment_resolved
157            )
158            ON stage.state = cr.state
159            AND stage.completion_eligible = p.completion_eligible
160            AND stage.has_verified_student_number = p.has_verified_student_number
161            AND stage.course_code_allowed = p.course_code_allowed
162            AND stage.enrolment_resolved = (cr.selected_enrolment_id IS NOT NULL)
163        WHERE cr.user_id = u.id
164          AND cr.course_id = $1
165          AND cr.superseded_by_id IS NULL
166          AND cr.deleted_at IS NULL
167          AND ($2::uuid IS NULL OR cr.course_instance_id = $2)
168          AND ($5::uuid IS NULL OR cr.course_module_id = $5)
169      )
170    )
171  GROUP BY u.id
172) t
173        "#,
174        course_id,
175        course_instance_id,
176        search_pattern.as_deref(),
177        user_id_exact,
178        module_id,
179        grade_filter,
180        &stages.states as &[CreditRegistrationState],
181        &stages.completion_eligible as &[bool],
182        &stages.has_verified_student_number as &[bool],
183        &stages.course_code_allowed as &[bool],
184        &stages.enrolment_resolved as &[bool],
185    )
186    .fetch_one(&mut *conn)
187    .await?;
188
189    // Each sort key appears twice, once per direction, and yields NULL in every row that the bound
190    // column and direction do not select -- an all-NULL key orders nothing, which is what lets one
191    // fixed ORDER BY stand in for the eight column/direction combinations. `u.id` breaks ties so
192    // paging over equal sort keys (duplicate/NULL names, duplicate emails) never skips or repeats a
193    // student.
194    let data = sqlx::query_as!(
195        CourseStudentListRow,
196        r#"
197SELECT
198  u.id AS "user_id!",
199  ud.first_name AS "first_name?",
200  ud.last_name AS "last_name?",
201  ud.email AS "email?",
202  COALESCE(
203    array_agg(DISTINCT ci.name) FILTER (WHERE ci.name IS NOT NULL),
204    ARRAY[]::text[]
205  ) AS "course_instances!: Vec<String>",
206  COALESCE(bool_or(ci.id IS NOT NULL), false) AS "has_active_instance!"
207FROM course_instance_enrollments cie
208  JOIN users u ON u.id = cie.user_id
209  LEFT JOIN user_details ud ON ud.user_id = u.id
210  LEFT JOIN course_instances ci
211    ON ci.id = cie.course_instance_id
212   AND ci.deleted_at IS NULL
213  LEFT JOIN LATERAL (
214    SELECT cmc.grade, cmc.passed
215    FROM course_module_completions cmc
216    WHERE cmc.user_id = u.id
217      AND cmc.course_id = $1
218      AND cmc.course_module_id = $7
219      AND cmc.deleted_at IS NULL
220    ORDER BY cmc.completion_date DESC
221    LIMIT 1
222  ) gm ON $7::uuid IS NOT NULL
223  LEFT JOIN (
224    SELECT ues.user_id, COALESCE(SUM(ues.score_given), 0)::double precision AS total_points
225    FROM user_exercise_states ues
226      JOIN exercises ex ON ex.id = ues.exercise_id
227    WHERE ues.course_id = $1
228      AND ues.deleted_at IS NULL
229      AND ex.deleted_at IS NULL
230    GROUP BY ues.user_id
231  ) points ON points.user_id = u.id
232WHERE cie.course_id = $1
233  AND cie.deleted_at IS NULL
234  AND u.deleted_at IS NULL
235  AND ($4::uuid IS NULL OR cie.course_instance_id = $4)
236  AND (
237    $2::text IS NULL
238    OR ud.name_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
239    OR ud.email_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
240    OR ($3::uuid IS NOT NULL AND u.id = $3)
241  )
242  AND (
243    $8::text IS NULL
244    OR ($8 = 'not_completed' AND gm.grade IS NULL AND gm.passed IS NULL)
245    OR ($8 = 'passed' AND gm.grade IS NULL AND gm.passed = true)
246    OR ($8 = 'failed' AND gm.grade IS NULL AND gm.passed = false)
247    OR ($8 ~ '^[0-9]+$' AND gm.grade = $8::int)
248  )
249  AND (
250    CARDINALITY($11::credit_registration_state []) = 0
251    OR EXISTS (
252      SELECT 1
253      FROM credit_registrations cr
254        JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
255        JOIN UNNEST(
256            $11::credit_registration_state [],
257            $12::boolean [],
258            $13::boolean [],
259            $14::boolean [],
260            $15::boolean []
261          ) AS stage(
262            state,
263            completion_eligible,
264            has_verified_student_number,
265            course_code_allowed,
266            enrolment_resolved
267          )
268          ON stage.state = cr.state
269          AND stage.completion_eligible = p.completion_eligible
270          AND stage.has_verified_student_number = p.has_verified_student_number
271          AND stage.course_code_allowed = p.course_code_allowed
272          AND stage.enrolment_resolved = (cr.selected_enrolment_id IS NOT NULL)
273      WHERE cr.user_id = u.id
274        AND cr.course_id = $1
275        AND cr.superseded_by_id IS NULL
276        AND cr.deleted_at IS NULL
277        AND ($4::uuid IS NULL OR cr.course_instance_id = $4)
278        AND ($7::uuid IS NULL OR cr.course_module_id = $7)
279    )
280  )
281GROUP BY u.id, ud.first_name, ud.last_name, ud.email
282ORDER BY
283  CASE
284    WHEN $9 = 'total_points' AND $10 = 'asc' THEN COALESCE(MAX(points.total_points), 0)
285  END ASC NULLS LAST,
286  CASE
287    WHEN $9 = 'total_points' AND $10 = 'desc' THEN COALESCE(MAX(points.total_points), 0)
288  END DESC NULLS LAST,
289  CASE
290    WHEN $10 <> 'asc' THEN NULL
291    WHEN $9 = 'first_name' THEN LOWER(TRIM(ud.first_name))
292    WHEN $9 = 'email' THEN LOWER(ud.email)
293    WHEN $9 = 'last_name' THEN LOWER(TRIM(ud.last_name))
294  END ASC NULLS LAST,
295  CASE
296    WHEN $10 <> 'desc' THEN NULL
297    WHEN $9 = 'first_name' THEN LOWER(TRIM(ud.first_name))
298    WHEN $9 = 'email' THEN LOWER(ud.email)
299    WHEN $9 = 'last_name' THEN LOWER(TRIM(ud.last_name))
300  END DESC NULLS LAST,
301  CASE
302    WHEN $9 = 'first_name' THEN LOWER(TRIM(ud.last_name))
303    WHEN $9 = 'last_name' THEN LOWER(TRIM(ud.first_name))
304  END ASC NULLS LAST,
305  u.id ASC
306LIMIT $5 OFFSET $6
307        "#,
308        course_id,
309        search_pattern.as_deref(),
310        user_id_exact,
311        course_instance_id,
312        pagination.limit(),
313        pagination.offset(),
314        module_id,
315        grade_filter,
316        sort_column,
317        sort_direction,
318        &stages.states as &[CreditRegistrationState],
319        &stages.completion_eligible as &[bool],
320        &stages.has_verified_student_number as &[bool],
321        &stages.course_code_allowed as &[bool],
322        &stages.enrolment_resolved as &[bool],
323    )
324    .fetch_all(&mut *conn)
325    .await?;
326
327    Ok(StudentsListPage {
328        data,
329        total_pages: pagination.total_pages(total_count as u32),
330    })
331}
332
333#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
334
335pub struct CompletionGridRow {
336    pub user_id: Uuid,
337    pub module_id: Uuid, // stable key for pivoting (module names are not unique)
338    pub module: Option<String>, // empty/default row can be None
339    pub grade: Option<i32>, // raw numeric grade, if any
340    pub passed: Option<bool>, // pass/fail when there is no numeric grade
341    pub registered: bool, // registered to a study registry
342    pub needs_to_be_reviewed: bool,
343}
344
345/// Returns student × module completion rows for the given users, keyed by `user_id`.
346pub async fn get_completions_grid_for_users(
347    conn: &mut PgConnection,
348    course_id: Uuid,
349    user_ids: &[Uuid],
350) -> ModelResult<Vec<CompletionGridRow>> {
351    let rows = sqlx::query_as!(
352        CompletionGridRow,
353        r#"
354WITH modules AS (
355  SELECT id AS module_id, name AS module_name, order_number
356  FROM course_modules
357  WHERE course_id = $1
358    AND deleted_at IS NULL
359),
360targets AS (
361  SELECT DISTINCT user_id
362  FROM course_instance_enrollments
363  WHERE course_id = $1
364    AND deleted_at IS NULL
365    AND user_id = ANY($2::uuid[])
366),
367latest_cmc AS (
368  SELECT DISTINCT ON (cmc.user_id, cmc.course_module_id)
369    cmc.id,
370    cmc.user_id,
371    cmc.course_module_id,
372    cmc.grade,
373    cmc.passed,
374    cmc.completion_date,
375    cmc.needs_to_be_reviewed
376  FROM course_module_completions cmc
377  WHERE cmc.course_id = $1
378    AND cmc.deleted_at IS NULL
379    AND cmc.user_id = ANY($2::uuid[])
380  ORDER BY cmc.user_id, cmc.course_module_id, cmc.completion_date DESC
381),
382cmcr AS (
383  SELECT course_module_completion_id
384  FROM course_module_completion_registered_to_study_registries
385  WHERE course_id = $1
386    AND deleted_at IS NULL
387)
388SELECT
389  e.user_id AS "user_id!",
390  m.module_id AS "module_id!",
391  m.module_name AS "module?",
392  r.grade AS "grade?",
393  r.passed AS "passed?",
394  (r.id IS NOT NULL AND r.id IN (SELECT course_module_completion_id FROM cmcr)) AS "registered!",
395  COALESCE(r.needs_to_be_reviewed, false) AS "needs_to_be_reviewed!"
396FROM modules m
397CROSS JOIN targets e
398LEFT JOIN latest_cmc r
399  ON r.user_id = e.user_id
400 AND r.course_module_id = m.module_id
401ORDER BY m.order_number, e.user_id
402        "#,
403        course_id,
404        user_ids
405    )
406    .fetch_all(&mut *conn)
407    .await?;
408
409    Ok(rows)
410}
411
412#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
413
414pub struct CertificateGridRow {
415    pub user_id: Uuid,
416    pub date_issued: Option<DateTime<Utc>>,
417    pub verification_id: Option<String>,
418    pub certificate_id: Option<Uuid>,
419    pub name_on_certificate: Option<String>,
420}
421
422/// Returns the latest course certificate (if any) for each of the given users, keyed by `user_id`.
423pub async fn get_certificates_grid_for_users(
424    conn: &mut PgConnection,
425    course_id: Uuid,
426    user_ids: &[Uuid],
427) -> ModelResult<Vec<CertificateGridRow>> {
428    let rows = sqlx::query_as!(
429        CertificateGridRow,
430        r#"
431WITH targets AS (
432  SELECT DISTINCT user_id
433  FROM course_instance_enrollments
434  WHERE course_id = $1
435    AND deleted_at IS NULL
436    AND user_id = ANY($2::uuid[])
437),
438user_certs AS (
439  -- one latest certificate per user for this course
440  SELECT DISTINCT ON (gc.user_id)
441    gc.user_id,
442    gc.id,
443    gc.created_at AS latest_issued_at,
444    gc.verification_id,
445    gc.name_on_certificate
446  FROM generated_certificates gc
447  JOIN certificate_configuration_to_requirements cctr
448    ON gc.certificate_configuration_id = cctr.certificate_configuration_id
449   AND cctr.deleted_at IS NULL
450  JOIN course_modules cm
451    ON cm.id = cctr.course_module_id
452   AND cm.deleted_at IS NULL
453  WHERE cm.course_id = $1
454    AND gc.deleted_at IS NULL
455    AND gc.user_id = ANY($2::uuid[])
456  ORDER BY gc.user_id, gc.created_at DESC
457)
458SELECT
459  e.user_id AS "user_id!",
460  uc.latest_issued_at AS "date_issued?",
461  uc.verification_id AS "verification_id?",
462  uc.id AS "certificate_id?",
463  uc.name_on_certificate AS "name_on_certificate?"
464FROM targets e
465LEFT JOIN user_certs uc ON uc.user_id = e.user_id
466        "#,
467        course_id,
468        user_ids
469    )
470    .fetch_all(&mut *conn)
471    .await?;
472
473    Ok(rows)
474}
475
476/// Course-level progress structure for the Progress tab. Does not depend on which students are on
477/// the current page, so it is fetched once and cached per course (not per identity page).
478#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
479
480pub struct CourseStudentsProgressStructure {
481    pub chapter_locking_enabled: bool,
482    pub chapters: Vec<DatabaseChapter>,
483    pub chapter_availability: Vec<ChapterAvailability>,
484}
485
486/// Per-user progress detail for the Progress tab, scoped to the requested `user_ids`.
487#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
488
489pub struct CourseStudentsProgressUsers {
490    pub user_chapter_progress: Vec<UserChapterProgress>,
491    pub user_chapter_locking_statuses: Vec<UserChapterLockingStatus>,
492}
493
494/// Returns the course-level chapter structure shared by every page of the Progress tab.
495pub async fn get_progress_structure(
496    conn: &mut PgConnection,
497    course_id: Uuid,
498) -> ModelResult<CourseStudentsProgressStructure> {
499    let course = crate::courses::get_course(conn, course_id).await?;
500    let chapters = crate::chapters::get_course_chapters(conn, course_id).await?;
501    let chapter_availability = chapters::fetch_chapter_availability(conn, course_id).await?;
502
503    Ok(CourseStudentsProgressStructure {
504        chapter_locking_enabled: course.chapter_locking_enabled,
505        chapters,
506        chapter_availability,
507    })
508}
509
510/// Returns per-user chapter progress and locking statuses for the given `user_ids`.
511pub async fn get_progress_for_users(
512    conn: &mut PgConnection,
513    course_id: Uuid,
514    user_ids: &[Uuid],
515) -> ModelResult<CourseStudentsProgressUsers> {
516    let course = crate::courses::get_course(conn, course_id).await?;
517    let user_chapter_progress =
518        chapters::fetch_user_chapter_progress(conn, course_id, Some(user_ids)).await?;
519    let user_chapter_locking_statuses =
520        crate::user_chapter_locking_statuses::get_for_users_and_course(conn, user_ids, &course)
521            .await?;
522
523    Ok(CourseStudentsProgressUsers {
524        user_chapter_progress,
525        user_chapter_locking_statuses,
526    })
527}