1use 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#[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 pub course_instances: Vec<String>,
18 pub has_active_instance: bool,
21}
22
23#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
25
26pub struct StudentsListPage {
27 pub data: Vec<CourseStudentListRow>,
28 pub total_pages: u32,
29}
30
31pub fn escape_like_pattern(input: &str) -> String {
34 input
35 .replace('\\', "\\\\")
36 .replace('%', "\\%")
37 .replace('_', "\\_")
38}
39
40pub const GRADE_FILTER_NOT_COMPLETED: &str = "not_completed";
43pub const GRADE_FILTER_PASSED: &str = "passed";
44pub const GRADE_FILTER_FAILED: &str = "failed";
45
46#[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 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 let search_pattern = search.map(|s| escape_like_pattern(&s.to_lowercase()));
79 let grade_filter = module_id.and(grade);
82
83 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 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, pub module: Option<String>, pub grade: Option<i32>, pub passed: Option<bool>, pub registered: bool, pub needs_to_be_reviewed: bool,
261}
262
263pub 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
340pub 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#[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#[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
412pub 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
428pub 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}