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 names and emails as [`crate::user_details::UserListSearch`] describes (every word, in any order,
53/// or a near match for a typo), plus an exact user-id match when it parses as a UUID.
54/// `course_instance_id` narrows to a 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 '\'`).
86    let search_pattern = search.map(|s| escape_like_pattern(&s.to_lowercase()));
87    let user_search = crate::user_details::UserListSearch::new(search.unwrap_or_default());
88    // A `grade` without a `module_id` has nothing to scope it to, so it is dropped rather than
89    // matched against every module.
90    let grade_filter = module_id.and(grade);
91
92    // Both the sort column and the direction are narrowed to a fixed literal here, so the query can
93    // bind them and stay one offline-checked shape instead of being built by string formatting.
94    let sort_column = match sort_column {
95        Some("first_name") => "first_name",
96        Some("email") => "email",
97        Some("total_points") => "total_points",
98        _ => "last_name",
99    };
100    let sort_direction = match sort_direction {
101        Some("desc") | Some("DESC") => "desc",
102        _ => "asc",
103    };
104
105    let total_count = sqlx::query_scalar!(
106        r#"
107SELECT COUNT(*) AS "count!"
108FROM (
109  SELECT u.id
110  FROM course_instance_enrollments cie
111    JOIN users u ON u.id = cie.user_id
112    LEFT JOIN user_details ud ON ud.user_id = u.id
113    LEFT JOIN LATERAL (
114      SELECT cmc.grade, cmc.passed
115      FROM course_module_completions cmc
116      WHERE cmc.user_id = u.id
117        AND cmc.course_id = $1
118        AND cmc.course_module_id = $5
119        AND cmc.deleted_at IS NULL
120      ORDER BY cmc.completion_date DESC
121      LIMIT 1
122    ) gm ON $5::uuid IS NOT NULL
123  WHERE cie.course_id = $1
124    AND cie.deleted_at IS NULL
125    AND u.deleted_at IS NULL
126    AND ($2::uuid IS NULL OR cie.course_instance_id = $2)
127    AND (
128      $3::text IS NULL
129      OR (ud.name_search_helper || ' ' || ud.email_search_helper) LIKE ALL ($12::text [])
130      OR word_similarity($13::text, ud.name_search_helper || ' ' || ud.email_search_helper) >= 0.5
131      OR ($4::uuid IS NOT NULL AND u.id = $4)
132    )
133    AND (
134      $6::text IS NULL
135      OR ($6 = 'not_completed' AND gm.grade IS NULL AND gm.passed IS NULL)
136      OR ($6 = 'passed' AND gm.grade IS NULL AND gm.passed = true)
137      OR ($6 = 'failed' AND gm.grade IS NULL AND gm.passed = false)
138      OR ($6 ~ '^[0-9]+$' AND gm.grade = $6::int)
139    )
140    AND (
141      CARDINALITY($7::credit_registration_state []) = 0
142      OR EXISTS (
143        SELECT 1
144        FROM credit_registrations cr
145          JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
146          JOIN UNNEST(
147              $7::credit_registration_state [],
148              $8::boolean [],
149              $9::boolean [],
150              $10::boolean [],
151              $11::boolean []
152            ) AS stage(
153              state,
154              completion_eligible,
155              has_verified_student_number,
156              course_code_allowed,
157              enrolment_resolved
158            )
159            ON stage.state = cr.state
160            AND stage.completion_eligible = p.completion_eligible
161            AND stage.has_verified_student_number = p.has_verified_student_number
162            AND stage.course_code_allowed = p.course_code_allowed
163            AND stage.enrolment_resolved = (cr.selected_enrolment_id IS NOT NULL)
164        WHERE cr.user_id = u.id
165          AND cr.course_id = $1
166          AND cr.superseded_by_id IS NULL
167          AND cr.deleted_at IS NULL
168          AND ($2::uuid IS NULL OR cr.course_instance_id = $2)
169          AND ($5::uuid IS NULL OR cr.course_module_id = $5)
170      )
171    )
172  GROUP BY u.id
173) t
174        "#,
175        course_id,
176        course_instance_id,
177        search_pattern.as_deref(),
178        user_id_exact,
179        module_id,
180        grade_filter,
181        &stages.states as &[CreditRegistrationState],
182        &stages.completion_eligible as &[bool],
183        &stages.has_verified_student_number as &[bool],
184        &stages.course_code_allowed as &[bool],
185        &stages.enrolment_resolved as &[bool],
186        &user_search.word_patterns,
187        user_search.misspelling_term.as_deref(),
188    )
189    .fetch_one(&mut *conn)
190    .await?;
191
192    // Each sort key appears twice, once per direction, and yields NULL in every row that the bound
193    // column and direction do not select -- an all-NULL key orders nothing, which is what lets one
194    // fixed ORDER BY stand in for the eight column/direction combinations. `u.id` breaks ties so
195    // paging over equal sort keys (duplicate/NULL names, duplicate emails) never skips or repeats a
196    // student.
197    let data = sqlx::query_as!(
198        CourseStudentListRow,
199        r#"
200SELECT
201  u.id AS "user_id!",
202  ud.first_name AS "first_name?",
203  ud.last_name AS "last_name?",
204  ud.email AS "email?",
205  COALESCE(
206    array_agg(DISTINCT ci.name) FILTER (WHERE ci.name IS NOT NULL),
207    ARRAY[]::text[]
208  ) AS "course_instances!: Vec<String>",
209  COALESCE(bool_or(ci.id IS NOT NULL), false) AS "has_active_instance!"
210FROM course_instance_enrollments cie
211  JOIN users u ON u.id = cie.user_id
212  LEFT JOIN user_details ud ON ud.user_id = u.id
213  LEFT JOIN course_instances ci
214    ON ci.id = cie.course_instance_id
215   AND ci.deleted_at IS NULL
216  LEFT JOIN LATERAL (
217    SELECT cmc.grade, cmc.passed
218    FROM course_module_completions cmc
219    WHERE cmc.user_id = u.id
220      AND cmc.course_id = $1
221      AND cmc.course_module_id = $7
222      AND cmc.deleted_at IS NULL
223    ORDER BY cmc.completion_date DESC
224    LIMIT 1
225  ) gm ON $7::uuid IS NOT NULL
226  LEFT JOIN (
227    SELECT ues.user_id, COALESCE(SUM(ues.score_given), 0)::double precision AS total_points
228    FROM user_exercise_states ues
229      JOIN exercises ex ON ex.id = ues.exercise_id
230    WHERE ues.course_id = $1
231      AND ues.deleted_at IS NULL
232      AND ex.deleted_at IS NULL
233    GROUP BY ues.user_id
234  ) points ON points.user_id = u.id
235WHERE cie.course_id = $1
236  AND cie.deleted_at IS NULL
237  AND u.deleted_at IS NULL
238  AND ($4::uuid IS NULL OR cie.course_instance_id = $4)
239  AND (
240    $2::text IS NULL
241    OR (ud.name_search_helper || ' ' || ud.email_search_helper) LIKE ALL ($16::text [])
242    OR word_similarity($17::text, ud.name_search_helper || ' ' || ud.email_search_helper) >= 0.5
243    OR ($3::uuid IS NOT NULL AND u.id = $3)
244  )
245  AND (
246    $8::text IS NULL
247    OR ($8 = 'not_completed' AND gm.grade IS NULL AND gm.passed IS NULL)
248    OR ($8 = 'passed' AND gm.grade IS NULL AND gm.passed = true)
249    OR ($8 = 'failed' AND gm.grade IS NULL AND gm.passed = false)
250    OR ($8 ~ '^[0-9]+$' AND gm.grade = $8::int)
251  )
252  AND (
253    CARDINALITY($11::credit_registration_state []) = 0
254    OR EXISTS (
255      SELECT 1
256      FROM credit_registrations cr
257        JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
258        JOIN UNNEST(
259            $11::credit_registration_state [],
260            $12::boolean [],
261            $13::boolean [],
262            $14::boolean [],
263            $15::boolean []
264          ) AS stage(
265            state,
266            completion_eligible,
267            has_verified_student_number,
268            course_code_allowed,
269            enrolment_resolved
270          )
271          ON stage.state = cr.state
272          AND stage.completion_eligible = p.completion_eligible
273          AND stage.has_verified_student_number = p.has_verified_student_number
274          AND stage.course_code_allowed = p.course_code_allowed
275          AND stage.enrolment_resolved = (cr.selected_enrolment_id IS NOT NULL)
276      WHERE cr.user_id = u.id
277        AND cr.course_id = $1
278        AND cr.superseded_by_id IS NULL
279        AND cr.deleted_at IS NULL
280        AND ($4::uuid IS NULL OR cr.course_instance_id = $4)
281        AND ($7::uuid IS NULL OR cr.course_module_id = $7)
282    )
283  )
284GROUP BY u.id, ud.first_name, ud.last_name, ud.email
285ORDER BY
286  CASE
287    WHEN $9 = 'total_points' AND $10 = 'asc' THEN COALESCE(MAX(points.total_points), 0)
288  END ASC NULLS LAST,
289  CASE
290    WHEN $9 = 'total_points' AND $10 = 'desc' THEN COALESCE(MAX(points.total_points), 0)
291  END DESC NULLS LAST,
292  CASE
293    WHEN $10 <> 'asc' THEN NULL
294    WHEN $9 = 'first_name' THEN LOWER(TRIM(ud.first_name))
295    WHEN $9 = 'email' THEN LOWER(ud.email)
296    WHEN $9 = 'last_name' THEN LOWER(TRIM(ud.last_name))
297  END ASC NULLS LAST,
298  CASE
299    WHEN $10 <> 'desc' THEN NULL
300    WHEN $9 = 'first_name' THEN LOWER(TRIM(ud.first_name))
301    WHEN $9 = 'email' THEN LOWER(ud.email)
302    WHEN $9 = 'last_name' THEN LOWER(TRIM(ud.last_name))
303  END DESC NULLS LAST,
304  CASE
305    WHEN $9 = 'first_name' THEN LOWER(TRIM(ud.last_name))
306    WHEN $9 = 'last_name' THEN LOWER(TRIM(ud.first_name))
307  END ASC NULLS LAST,
308  u.id ASC
309LIMIT $5 OFFSET $6
310        "#,
311        course_id,
312        search_pattern.as_deref(),
313        user_id_exact,
314        course_instance_id,
315        pagination.limit(),
316        pagination.offset(),
317        module_id,
318        grade_filter,
319        sort_column,
320        sort_direction,
321        &stages.states as &[CreditRegistrationState],
322        &stages.completion_eligible as &[bool],
323        &stages.has_verified_student_number as &[bool],
324        &stages.course_code_allowed as &[bool],
325        &stages.enrolment_resolved as &[bool],
326        &user_search.word_patterns,
327        user_search.misspelling_term.as_deref(),
328    )
329    .fetch_all(&mut *conn)
330    .await?;
331
332    Ok(StudentsListPage {
333        data,
334        total_pages: pagination.total_pages(total_count as u32),
335    })
336}
337
338#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
339
340pub struct CompletionGridRow {
341    pub user_id: Uuid,
342    pub module_id: Uuid, // stable key for pivoting (module names are not unique)
343    pub module: Option<String>, // empty/default row can be None
344    pub grade: Option<i32>, // raw numeric grade, if any
345    pub passed: Option<bool>, // pass/fail when there is no numeric grade
346    pub registered: bool, // registered to a study registry
347    pub needs_to_be_reviewed: bool,
348}
349
350/// Returns student × module completion rows for the given users, keyed by `user_id`.
351pub async fn get_completions_grid_for_users(
352    conn: &mut PgConnection,
353    course_id: Uuid,
354    user_ids: &[Uuid],
355) -> ModelResult<Vec<CompletionGridRow>> {
356    let rows = sqlx::query_as!(
357        CompletionGridRow,
358        r#"
359WITH modules AS (
360  SELECT id AS module_id, name AS module_name, order_number
361  FROM course_modules
362  WHERE course_id = $1
363    AND deleted_at IS NULL
364),
365targets AS (
366  SELECT DISTINCT user_id
367  FROM course_instance_enrollments
368  WHERE course_id = $1
369    AND deleted_at IS NULL
370    AND user_id = ANY($2::uuid[])
371),
372latest_cmc AS (
373  SELECT DISTINCT ON (cmc.user_id, cmc.course_module_id)
374    cmc.id,
375    cmc.user_id,
376    cmc.course_module_id,
377    cmc.grade,
378    cmc.passed,
379    cmc.completion_date,
380    cmc.needs_to_be_reviewed
381  FROM course_module_completions cmc
382  WHERE cmc.course_id = $1
383    AND cmc.deleted_at IS NULL
384    AND cmc.user_id = ANY($2::uuid[])
385  ORDER BY cmc.user_id, cmc.course_module_id, cmc.completion_date DESC
386),
387cmcr AS (
388  SELECT course_module_completion_id
389  FROM course_module_completion_registered_to_study_registries
390  WHERE course_id = $1
391    AND deleted_at IS NULL
392)
393SELECT
394  e.user_id AS "user_id!",
395  m.module_id AS "module_id!",
396  m.module_name AS "module?",
397  r.grade AS "grade?",
398  r.passed AS "passed?",
399  (r.id IS NOT NULL AND r.id IN (SELECT course_module_completion_id FROM cmcr)) AS "registered!",
400  COALESCE(r.needs_to_be_reviewed, false) AS "needs_to_be_reviewed!"
401FROM modules m
402CROSS JOIN targets e
403LEFT JOIN latest_cmc r
404  ON r.user_id = e.user_id
405 AND r.course_module_id = m.module_id
406ORDER BY m.order_number, e.user_id
407        "#,
408        course_id,
409        user_ids
410    )
411    .fetch_all(&mut *conn)
412    .await?;
413
414    Ok(rows)
415}
416
417#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
418
419pub struct CertificateGridRow {
420    pub user_id: Uuid,
421    pub date_issued: Option<DateTime<Utc>>,
422    pub verification_id: Option<String>,
423    pub certificate_id: Option<Uuid>,
424    pub name_on_certificate: Option<String>,
425}
426
427/// Returns the latest course certificate (if any) for each of the given users, keyed by `user_id`.
428pub async fn get_certificates_grid_for_users(
429    conn: &mut PgConnection,
430    course_id: Uuid,
431    user_ids: &[Uuid],
432) -> ModelResult<Vec<CertificateGridRow>> {
433    let rows = sqlx::query_as!(
434        CertificateGridRow,
435        r#"
436WITH targets AS (
437  SELECT DISTINCT user_id
438  FROM course_instance_enrollments
439  WHERE course_id = $1
440    AND deleted_at IS NULL
441    AND user_id = ANY($2::uuid[])
442),
443user_certs AS (
444  -- one latest certificate per user for this course
445  SELECT DISTINCT ON (gc.user_id)
446    gc.user_id,
447    gc.id,
448    gc.created_at AS latest_issued_at,
449    gc.verification_id,
450    gc.name_on_certificate
451  FROM generated_certificates gc
452  JOIN certificate_configuration_to_requirements cctr
453    ON gc.certificate_configuration_id = cctr.certificate_configuration_id
454   AND cctr.deleted_at IS NULL
455  JOIN course_modules cm
456    ON cm.id = cctr.course_module_id
457   AND cm.deleted_at IS NULL
458  WHERE cm.course_id = $1
459    AND gc.deleted_at IS NULL
460    AND gc.user_id = ANY($2::uuid[])
461  ORDER BY gc.user_id, gc.created_at DESC
462)
463SELECT
464  e.user_id AS "user_id!",
465  uc.latest_issued_at AS "date_issued?",
466  uc.verification_id AS "verification_id?",
467  uc.id AS "certificate_id?",
468  uc.name_on_certificate AS "name_on_certificate?"
469FROM targets e
470LEFT JOIN user_certs uc ON uc.user_id = e.user_id
471        "#,
472        course_id,
473        user_ids
474    )
475    .fetch_all(&mut *conn)
476    .await?;
477
478    Ok(rows)
479}
480
481/// Course-level progress structure for the Progress tab. Does not depend on which students are on
482/// the current page, so it is fetched once and cached per course (not per identity page).
483#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
484
485pub struct CourseStudentsProgressStructure {
486    pub chapter_locking_enabled: bool,
487    pub chapters: Vec<DatabaseChapter>,
488    pub chapter_availability: Vec<ChapterAvailability>,
489}
490
491/// Per-user progress detail for the Progress tab, scoped to the requested `user_ids`.
492#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
493
494pub struct CourseStudentsProgressUsers {
495    pub user_chapter_progress: Vec<UserChapterProgress>,
496    pub user_chapter_locking_statuses: Vec<UserChapterLockingStatus>,
497}
498
499/// Returns the course-level chapter structure shared by every page of the Progress tab.
500pub async fn get_progress_structure(
501    conn: &mut PgConnection,
502    course_id: Uuid,
503) -> ModelResult<CourseStudentsProgressStructure> {
504    let course = crate::courses::get_course(conn, course_id).await?;
505    let chapters = crate::chapters::get_course_chapters(conn, course_id).await?;
506    let chapter_availability = chapters::fetch_chapter_availability(conn, course_id).await?;
507
508    Ok(CourseStudentsProgressStructure {
509        chapter_locking_enabled: course.chapter_locking_enabled,
510        chapters,
511        chapter_availability,
512    })
513}
514
515/// Returns per-user chapter progress and locking statuses for the given `user_ids`.
516pub async fn get_progress_for_users(
517    conn: &mut PgConnection,
518    course_id: Uuid,
519    user_ids: &[Uuid],
520) -> ModelResult<CourseStudentsProgressUsers> {
521    let course = crate::courses::get_course(conn, course_id).await?;
522    let user_chapter_progress =
523        chapters::fetch_user_chapter_progress(conn, course_id, Some(user_ids)).await?;
524    let user_chapter_locking_statuses =
525        crate::user_chapter_locking_statuses::get_for_users_and_course(conn, user_ids, &course)
526            .await?;
527
528    Ok(CourseStudentsProgressUsers {
529        user_chapter_progress,
530        user_chapter_locking_statuses,
531    })
532}