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 sqlx::AssertSqlSafe;
7use utoipa::ToSchema;
8
9/// One row of the paginated student identity list (one row per distinct enrolled user).
10#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
11
12pub struct CourseStudentListRow {
13    pub user_id: Uuid,
14    pub first_name: Option<String>,
15    pub last_name: Option<String>,
16    pub email: Option<String>,
17    /// Names of the non-deleted course instances the user is enrolled in for this course.
18    pub course_instances: Vec<String>,
19    /// Whether the user has any enrollment into a non-deleted instance. Separates the unnamed default
20    /// instance (true) from a since-deleted instance (false) when `course_instances` is empty.
21    pub has_active_instance: bool,
22}
23
24/// A page of the student identity list plus the total number of pages for the current filters.
25#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
26
27pub struct StudentsListPage {
28    pub data: Vec<CourseStudentListRow>,
29    pub total_pages: u32,
30}
31
32/// Escapes the `LIKE`/`ILIKE` metacharacters `\`, `%` and `_` so a search string is matched
33/// literally (used together with `ESCAPE '\'` in the query).
34fn escape_like_pattern(input: &str) -> String {
35    input
36        .replace('\\', "\\\\")
37        .replace('%', "\\%")
38        .replace('_', "\\_")
39}
40
41/// Returns a filtered, sorted, paginated page of the course's enrolled users (identity only).
42///
43/// `sort_column` (`last_name` | `first_name` | `email`) and `sort_direction` map to fixed SQL
44/// fragments, never interpolated from raw input. `search` matches name/email substrings via the
45/// trigram `name_search_helper` / `email_search_helper` columns, plus an exact user-id match when it
46/// parses as a UUID. `course_instance_id` narrows to a single instance.
47pub async fn get_course_students_page(
48    conn: &mut PgConnection,
49    course_id: Uuid,
50    pagination: Pagination,
51    search: Option<&str>,
52    sort_column: Option<&str>,
53    sort_direction: Option<&str>,
54    course_instance_id: Option<Uuid>,
55) -> ModelResult<StudentsListPage> {
56    // Empty/blank search behaves like no search.
57    let search = search.map(str::trim).filter(|s| !s.is_empty());
58    let user_id_exact = search.and_then(|s| Uuid::parse_str(s).ok());
59    // The helper columns are lowercased generated columns, so lowercase the term and escape the LIKE
60    // metacharacters (matched literally via `ESCAPE '\'`). The GiST trigram indexes serve LIKE.
61    let search_pattern = search.map(|s| escape_like_pattern(&s.to_lowercase()));
62
63    let total_count = sqlx::query!(
64        r#"
65SELECT COUNT(*) AS "count!"
66FROM (
67  SELECT u.id
68  FROM course_instance_enrollments cie
69    JOIN users u ON u.id = cie.user_id
70    LEFT JOIN user_details ud ON ud.user_id = u.id
71  WHERE cie.course_id = $1
72    AND cie.deleted_at IS NULL
73    AND u.deleted_at IS NULL
74    AND ($2::uuid IS NULL OR cie.course_instance_id = $2)
75    AND (
76      $3::text IS NULL
77      OR ud.name_search_helper LIKE '%' || $3 || '%' ESCAPE '\'
78      OR ud.email_search_helper LIKE '%' || $3 || '%' ESCAPE '\'
79      OR ($4::uuid IS NOT NULL AND u.id = $4)
80    )
81  GROUP BY u.id
82) t
83        "#,
84        course_id,
85        course_instance_id,
86        search_pattern.as_deref(),
87        user_id_exact
88    )
89    .fetch_one(&mut *conn)
90    .await?
91    .count;
92
93    // Sort column and direction are matched to fixed literals; only bound params carry user data.
94    let dir = match sort_direction {
95        Some("desc") | Some("DESC") => "DESC",
96        _ => "ASC",
97    };
98    // `u.id` breaks ties so paging over equal sort keys (duplicate/NULL names, duplicate emails) is
99    // deterministic and never skips or repeats a student.
100    let order_by = match sort_column {
101        Some("first_name") => {
102            format!(
103                "LOWER(TRIM(ud.first_name)) {dir} NULLS LAST, LOWER(TRIM(ud.last_name)) ASC NULLS LAST, u.id ASC"
104            )
105        }
106        Some("email") => format!("LOWER(ud.email) {dir} NULLS LAST, u.id ASC"),
107        _ => {
108            format!(
109                "LOWER(TRIM(ud.last_name)) {dir} NULLS LAST, LOWER(TRIM(ud.first_name)) ASC NULLS LAST, u.id ASC"
110            )
111        }
112    };
113
114    let page_sql = format!(
115        r#"
116SELECT
117  u.id AS user_id,
118  ud.first_name AS first_name,
119  ud.last_name AS last_name,
120  ud.email AS email,
121  COALESCE(
122    array_agg(DISTINCT ci.name) FILTER (WHERE ci.name IS NOT NULL),
123    ARRAY[]::text[]
124  ) AS course_instances,
125  COALESCE(bool_or(ci.id IS NOT NULL), false) AS has_active_instance
126FROM course_instance_enrollments cie
127  JOIN users u ON u.id = cie.user_id
128  LEFT JOIN user_details ud ON ud.user_id = u.id
129  LEFT JOIN course_instances ci
130    ON ci.id = cie.course_instance_id
131   AND ci.deleted_at IS NULL
132WHERE cie.course_id = $1
133  AND cie.deleted_at IS NULL
134  AND u.deleted_at IS NULL
135  AND ($4::uuid IS NULL OR cie.course_instance_id = $4)
136  AND (
137    $2::text IS NULL
138    OR ud.name_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
139    OR ud.email_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
140    OR ($3::uuid IS NOT NULL AND u.id = $3)
141  )
142GROUP BY u.id, ud.first_name, ud.last_name, ud.email
143ORDER BY {order_by}
144LIMIT $5 OFFSET $6
145        "#
146    );
147
148    let data = sqlx::query_as::<_, CourseStudentListRow>(AssertSqlSafe(page_sql))
149        .bind(course_id)
150        .bind(search_pattern.as_deref())
151        .bind(user_id_exact)
152        .bind(course_instance_id)
153        .bind(pagination.limit())
154        .bind(pagination.offset())
155        .fetch_all(&mut *conn)
156        .await?;
157
158    Ok(StudentsListPage {
159        data,
160        total_pages: pagination.total_pages(total_count as u32),
161    })
162}
163
164#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
165
166pub struct CompletionGridRow {
167    pub user_id: Uuid,
168    pub module_id: Uuid, // stable key for pivoting (module names are not unique)
169    pub module: Option<String>, // empty/default row can be None
170    pub grade: Option<i32>, // raw numeric grade, if any
171    pub passed: Option<bool>, // pass/fail when there is no numeric grade
172    pub registered: bool, // registered to a study registry
173    pub needs_to_be_reviewed: bool,
174}
175
176/// Returns student × module completion rows for the given users, keyed by `user_id`.
177pub async fn get_completions_grid_for_users(
178    conn: &mut PgConnection,
179    course_id: Uuid,
180    user_ids: &[Uuid],
181) -> ModelResult<Vec<CompletionGridRow>> {
182    let rows = sqlx::query_as!(
183        CompletionGridRow,
184        r#"
185WITH modules AS (
186  SELECT id AS module_id, name AS module_name, order_number
187  FROM course_modules
188  WHERE course_id = $1
189    AND deleted_at IS NULL
190),
191targets AS (
192  SELECT DISTINCT user_id
193  FROM course_instance_enrollments
194  WHERE course_id = $1
195    AND deleted_at IS NULL
196    AND user_id = ANY($2::uuid[])
197),
198latest_cmc AS (
199  SELECT DISTINCT ON (cmc.user_id, cmc.course_module_id)
200    cmc.id,
201    cmc.user_id,
202    cmc.course_module_id,
203    cmc.grade,
204    cmc.passed,
205    cmc.completion_date,
206    cmc.needs_to_be_reviewed
207  FROM course_module_completions cmc
208  WHERE cmc.course_id = $1
209    AND cmc.deleted_at IS NULL
210    AND cmc.user_id = ANY($2::uuid[])
211  ORDER BY cmc.user_id, cmc.course_module_id, cmc.completion_date DESC
212),
213cmcr AS (
214  SELECT course_module_completion_id
215  FROM course_module_completion_registered_to_study_registries
216  WHERE course_id = $1
217    AND deleted_at IS NULL
218)
219SELECT
220  e.user_id AS "user_id!",
221  m.module_id AS "module_id!",
222  m.module_name AS "module?",
223  r.grade AS "grade?",
224  r.passed AS "passed?",
225  (r.id IS NOT NULL AND r.id IN (SELECT course_module_completion_id FROM cmcr)) AS "registered!",
226  COALESCE(r.needs_to_be_reviewed, false) AS "needs_to_be_reviewed!"
227FROM modules m
228CROSS JOIN targets e
229LEFT JOIN latest_cmc r
230  ON r.user_id = e.user_id
231 AND r.course_module_id = m.module_id
232ORDER BY m.order_number, e.user_id
233        "#,
234        course_id,
235        user_ids
236    )
237    .fetch_all(&mut *conn)
238    .await?;
239
240    Ok(rows)
241}
242
243#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
244
245pub struct CertificateGridRow {
246    pub user_id: Uuid,
247    pub date_issued: Option<DateTime<Utc>>,
248    pub verification_id: Option<String>,
249    pub certificate_id: Option<Uuid>,
250    pub name_on_certificate: Option<String>,
251}
252
253/// Returns the latest course certificate (if any) for each of the given users, keyed by `user_id`.
254pub async fn get_certificates_grid_for_users(
255    conn: &mut PgConnection,
256    course_id: Uuid,
257    user_ids: &[Uuid],
258) -> ModelResult<Vec<CertificateGridRow>> {
259    let rows = sqlx::query_as!(
260        CertificateGridRow,
261        r#"
262WITH targets AS (
263  SELECT DISTINCT user_id
264  FROM course_instance_enrollments
265  WHERE course_id = $1
266    AND deleted_at IS NULL
267    AND user_id = ANY($2::uuid[])
268),
269user_certs AS (
270  -- one latest certificate per user for this course
271  SELECT DISTINCT ON (gc.user_id)
272    gc.user_id,
273    gc.id,
274    gc.created_at AS latest_issued_at,
275    gc.verification_id,
276    gc.name_on_certificate
277  FROM generated_certificates gc
278  JOIN certificate_configuration_to_requirements cctr
279    ON gc.certificate_configuration_id = cctr.certificate_configuration_id
280   AND cctr.deleted_at IS NULL
281  JOIN course_modules cm
282    ON cm.id = cctr.course_module_id
283   AND cm.deleted_at IS NULL
284  WHERE cm.course_id = $1
285    AND gc.deleted_at IS NULL
286    AND gc.user_id = ANY($2::uuid[])
287  ORDER BY gc.user_id, gc.created_at DESC
288)
289SELECT
290  e.user_id AS "user_id!",
291  uc.latest_issued_at AS "date_issued?",
292  uc.verification_id AS "verification_id?",
293  uc.id AS "certificate_id?",
294  uc.name_on_certificate AS "name_on_certificate?"
295FROM targets e
296LEFT JOIN user_certs uc ON uc.user_id = e.user_id
297        "#,
298        course_id,
299        user_ids
300    )
301    .fetch_all(&mut *conn)
302    .await?;
303
304    Ok(rows)
305}
306
307/// Course-level progress structure for the Progress tab. Does not depend on which students are on
308/// the current page, so it is fetched once and cached per course (not per identity page).
309#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
310
311pub struct CourseStudentsProgressStructure {
312    pub chapter_locking_enabled: bool,
313    pub chapters: Vec<DatabaseChapter>,
314    pub chapter_availability: Vec<ChapterAvailability>,
315}
316
317/// Per-user progress detail for the Progress tab, scoped to the requested `user_ids`.
318#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
319
320pub struct CourseStudentsProgressUsers {
321    pub user_chapter_progress: Vec<UserChapterProgress>,
322    pub user_chapter_locking_statuses: Vec<UserChapterLockingStatus>,
323}
324
325/// Returns the course-level chapter structure shared by every page of the Progress tab.
326pub async fn get_progress_structure(
327    conn: &mut PgConnection,
328    course_id: Uuid,
329) -> ModelResult<CourseStudentsProgressStructure> {
330    let course = crate::courses::get_course(conn, course_id).await?;
331    let chapters = crate::chapters::get_course_chapters(conn, course_id).await?;
332    let chapter_availability = chapters::fetch_chapter_availability(conn, course_id).await?;
333
334    Ok(CourseStudentsProgressStructure {
335        chapter_locking_enabled: course.chapter_locking_enabled,
336        chapters,
337        chapter_availability,
338    })
339}
340
341/// Returns per-user chapter progress and locking statuses for the given `user_ids`.
342pub async fn get_progress_for_users(
343    conn: &mut PgConnection,
344    course_id: Uuid,
345    user_ids: &[Uuid],
346) -> ModelResult<CourseStudentsProgressUsers> {
347    let course = crate::courses::get_course(conn, course_id).await?;
348    let user_chapter_progress =
349        chapters::fetch_user_chapter_progress(conn, course_id, Some(user_ids)).await?;
350    let user_chapter_locking_statuses =
351        crate::user_chapter_locking_statuses::get_for_users_and_course(conn, user_ids, &course)
352            .await?;
353
354    Ok(CourseStudentsProgressUsers {
355        user_chapter_progress,
356        user_chapter_locking_statuses,
357    })
358}