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