headless_lms_models/library/
students_view.rs1use 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#[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 pub course_instances: Vec<String>,
19 pub has_active_instance: bool,
22}
23
24#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
26
27pub struct StudentsListPage {
28 pub data: Vec<CourseStudentListRow>,
29 pub total_pages: u32,
30}
31
32fn escape_like_pattern(input: &str) -> String {
35 input
36 .replace('\\', "\\\\")
37 .replace('%', "\\%")
38 .replace('_', "\\_")
39}
40
41pub 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 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 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 let dir = match sort_direction {
95 Some("desc") | Some("DESC") => "DESC",
96 _ => "ASC",
97 };
98 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, pub module: Option<String>, pub grade: Option<i32>, pub passed: Option<bool>, pub registered: bool, pub needs_to_be_reviewed: bool,
174}
175
176pub 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
253pub 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#[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#[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
325pub 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
341pub 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}