1use 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#[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 pub course_instances: Vec<String>,
20 pub has_active_instance: bool,
23}
24
25#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
27
28pub struct StudentsListPage {
29 pub data: Vec<CourseStudentListRow>,
30 pub total_pages: u32,
31}
32
33pub fn escape_like_pattern(input: &str) -> String {
36 input
37 .replace('\\', "\\\\")
38 .replace('%', "\\%")
39 .replace('_', "\\_")
40}
41
42pub const GRADE_FILTER_NOT_COMPLETED: &str = "not_completed";
45pub const GRADE_FILTER_PASSED: &str = "passed";
46pub const GRADE_FILTER_FAILED: &str = "failed";
47
48#[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 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 let search_pattern = search.map(|s| escape_like_pattern(&s.to_lowercase()));
87 let grade_filter = module_id.and(grade);
90
91 let sort_column = match sort_column {
94 Some("first_name") => "first_name",
95 Some("email") => "email",
96 Some("total_points") => "total_points",
97 _ => "last_name",
98 };
99 let sort_direction = match sort_direction {
100 Some("desc") | Some("DESC") => "desc",
101 _ => "asc",
102 };
103
104 let total_count = sqlx::query_scalar!(
105 r#"
106SELECT COUNT(*) AS "count!"
107FROM (
108 SELECT u.id
109 FROM course_instance_enrollments cie
110 JOIN users u ON u.id = cie.user_id
111 LEFT JOIN user_details ud ON ud.user_id = u.id
112 LEFT JOIN LATERAL (
113 SELECT cmc.grade, cmc.passed
114 FROM course_module_completions cmc
115 WHERE cmc.user_id = u.id
116 AND cmc.course_id = $1
117 AND cmc.course_module_id = $5
118 AND cmc.deleted_at IS NULL
119 ORDER BY cmc.completion_date DESC
120 LIMIT 1
121 ) gm ON $5::uuid IS NOT NULL
122 WHERE cie.course_id = $1
123 AND cie.deleted_at IS NULL
124 AND u.deleted_at IS NULL
125 AND ($2::uuid IS NULL OR cie.course_instance_id = $2)
126 AND (
127 $3::text IS NULL
128 OR ud.name_search_helper LIKE '%' || $3 || '%' ESCAPE '\'
129 OR ud.email_search_helper LIKE '%' || $3 || '%' ESCAPE '\'
130 OR ($4::uuid IS NOT NULL AND u.id = $4)
131 )
132 AND (
133 $6::text IS NULL
134 OR ($6 = 'not_completed' AND gm.grade IS NULL AND gm.passed IS NULL)
135 OR ($6 = 'passed' AND gm.grade IS NULL AND gm.passed = true)
136 OR ($6 = 'failed' AND gm.grade IS NULL AND gm.passed = false)
137 OR ($6 ~ '^[0-9]+$' AND gm.grade = $6::int)
138 )
139 AND (
140 CARDINALITY($7::credit_registration_state []) = 0
141 OR EXISTS (
142 SELECT 1
143 FROM credit_registrations cr
144 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
145 JOIN UNNEST(
146 $7::credit_registration_state [],
147 $8::boolean [],
148 $9::boolean [],
149 $10::boolean [],
150 $11::boolean []
151 ) AS stage(
152 state,
153 completion_eligible,
154 has_verified_student_number,
155 course_code_allowed,
156 enrolment_resolved
157 )
158 ON stage.state = cr.state
159 AND stage.completion_eligible = p.completion_eligible
160 AND stage.has_verified_student_number = p.has_verified_student_number
161 AND stage.course_code_allowed = p.course_code_allowed
162 AND stage.enrolment_resolved = (cr.selected_enrolment_id IS NOT NULL)
163 WHERE cr.user_id = u.id
164 AND cr.course_id = $1
165 AND cr.superseded_by_id IS NULL
166 AND cr.deleted_at IS NULL
167 AND ($2::uuid IS NULL OR cr.course_instance_id = $2)
168 AND ($5::uuid IS NULL OR cr.course_module_id = $5)
169 )
170 )
171 GROUP BY u.id
172) t
173 "#,
174 course_id,
175 course_instance_id,
176 search_pattern.as_deref(),
177 user_id_exact,
178 module_id,
179 grade_filter,
180 &stages.states as &[CreditRegistrationState],
181 &stages.completion_eligible as &[bool],
182 &stages.has_verified_student_number as &[bool],
183 &stages.course_code_allowed as &[bool],
184 &stages.enrolment_resolved as &[bool],
185 )
186 .fetch_one(&mut *conn)
187 .await?;
188
189 let data = sqlx::query_as!(
195 CourseStudentListRow,
196 r#"
197SELECT
198 u.id AS "user_id!",
199 ud.first_name AS "first_name?",
200 ud.last_name AS "last_name?",
201 ud.email AS "email?",
202 COALESCE(
203 array_agg(DISTINCT ci.name) FILTER (WHERE ci.name IS NOT NULL),
204 ARRAY[]::text[]
205 ) AS "course_instances!: Vec<String>",
206 COALESCE(bool_or(ci.id IS NOT NULL), false) AS "has_active_instance!"
207FROM course_instance_enrollments cie
208 JOIN users u ON u.id = cie.user_id
209 LEFT JOIN user_details ud ON ud.user_id = u.id
210 LEFT JOIN course_instances ci
211 ON ci.id = cie.course_instance_id
212 AND ci.deleted_at IS NULL
213 LEFT JOIN LATERAL (
214 SELECT cmc.grade, cmc.passed
215 FROM course_module_completions cmc
216 WHERE cmc.user_id = u.id
217 AND cmc.course_id = $1
218 AND cmc.course_module_id = $7
219 AND cmc.deleted_at IS NULL
220 ORDER BY cmc.completion_date DESC
221 LIMIT 1
222 ) gm ON $7::uuid IS NOT NULL
223 LEFT JOIN (
224 SELECT ues.user_id, COALESCE(SUM(ues.score_given), 0)::double precision AS total_points
225 FROM user_exercise_states ues
226 JOIN exercises ex ON ex.id = ues.exercise_id
227 WHERE ues.course_id = $1
228 AND ues.deleted_at IS NULL
229 AND ex.deleted_at IS NULL
230 GROUP BY ues.user_id
231 ) points ON points.user_id = u.id
232WHERE cie.course_id = $1
233 AND cie.deleted_at IS NULL
234 AND u.deleted_at IS NULL
235 AND ($4::uuid IS NULL OR cie.course_instance_id = $4)
236 AND (
237 $2::text IS NULL
238 OR ud.name_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
239 OR ud.email_search_helper LIKE '%' || $2 || '%' ESCAPE '\'
240 OR ($3::uuid IS NOT NULL AND u.id = $3)
241 )
242 AND (
243 $8::text IS NULL
244 OR ($8 = 'not_completed' AND gm.grade IS NULL AND gm.passed IS NULL)
245 OR ($8 = 'passed' AND gm.grade IS NULL AND gm.passed = true)
246 OR ($8 = 'failed' AND gm.grade IS NULL AND gm.passed = false)
247 OR ($8 ~ '^[0-9]+$' AND gm.grade = $8::int)
248 )
249 AND (
250 CARDINALITY($11::credit_registration_state []) = 0
251 OR EXISTS (
252 SELECT 1
253 FROM credit_registrations cr
254 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
255 JOIN UNNEST(
256 $11::credit_registration_state [],
257 $12::boolean [],
258 $13::boolean [],
259 $14::boolean [],
260 $15::boolean []
261 ) AS stage(
262 state,
263 completion_eligible,
264 has_verified_student_number,
265 course_code_allowed,
266 enrolment_resolved
267 )
268 ON stage.state = cr.state
269 AND stage.completion_eligible = p.completion_eligible
270 AND stage.has_verified_student_number = p.has_verified_student_number
271 AND stage.course_code_allowed = p.course_code_allowed
272 AND stage.enrolment_resolved = (cr.selected_enrolment_id IS NOT NULL)
273 WHERE cr.user_id = u.id
274 AND cr.course_id = $1
275 AND cr.superseded_by_id IS NULL
276 AND cr.deleted_at IS NULL
277 AND ($4::uuid IS NULL OR cr.course_instance_id = $4)
278 AND ($7::uuid IS NULL OR cr.course_module_id = $7)
279 )
280 )
281GROUP BY u.id, ud.first_name, ud.last_name, ud.email
282ORDER BY
283 CASE
284 WHEN $9 = 'total_points' AND $10 = 'asc' THEN COALESCE(MAX(points.total_points), 0)
285 END ASC NULLS LAST,
286 CASE
287 WHEN $9 = 'total_points' AND $10 = 'desc' THEN COALESCE(MAX(points.total_points), 0)
288 END DESC NULLS LAST,
289 CASE
290 WHEN $10 <> 'asc' THEN NULL
291 WHEN $9 = 'first_name' THEN LOWER(TRIM(ud.first_name))
292 WHEN $9 = 'email' THEN LOWER(ud.email)
293 WHEN $9 = 'last_name' THEN LOWER(TRIM(ud.last_name))
294 END ASC NULLS LAST,
295 CASE
296 WHEN $10 <> 'desc' THEN NULL
297 WHEN $9 = 'first_name' THEN LOWER(TRIM(ud.first_name))
298 WHEN $9 = 'email' THEN LOWER(ud.email)
299 WHEN $9 = 'last_name' THEN LOWER(TRIM(ud.last_name))
300 END DESC NULLS LAST,
301 CASE
302 WHEN $9 = 'first_name' THEN LOWER(TRIM(ud.last_name))
303 WHEN $9 = 'last_name' THEN LOWER(TRIM(ud.first_name))
304 END ASC NULLS LAST,
305 u.id ASC
306LIMIT $5 OFFSET $6
307 "#,
308 course_id,
309 search_pattern.as_deref(),
310 user_id_exact,
311 course_instance_id,
312 pagination.limit(),
313 pagination.offset(),
314 module_id,
315 grade_filter,
316 sort_column,
317 sort_direction,
318 &stages.states as &[CreditRegistrationState],
319 &stages.completion_eligible as &[bool],
320 &stages.has_verified_student_number as &[bool],
321 &stages.course_code_allowed as &[bool],
322 &stages.enrolment_resolved as &[bool],
323 )
324 .fetch_all(&mut *conn)
325 .await?;
326
327 Ok(StudentsListPage {
328 data,
329 total_pages: pagination.total_pages(total_count as u32),
330 })
331}
332
333#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
334
335pub struct CompletionGridRow {
336 pub user_id: Uuid,
337 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,
343}
344
345pub async fn get_completions_grid_for_users(
347 conn: &mut PgConnection,
348 course_id: Uuid,
349 user_ids: &[Uuid],
350) -> ModelResult<Vec<CompletionGridRow>> {
351 let rows = sqlx::query_as!(
352 CompletionGridRow,
353 r#"
354WITH modules AS (
355 SELECT id AS module_id, name AS module_name, order_number
356 FROM course_modules
357 WHERE course_id = $1
358 AND deleted_at IS NULL
359),
360targets AS (
361 SELECT DISTINCT user_id
362 FROM course_instance_enrollments
363 WHERE course_id = $1
364 AND deleted_at IS NULL
365 AND user_id = ANY($2::uuid[])
366),
367latest_cmc AS (
368 SELECT DISTINCT ON (cmc.user_id, cmc.course_module_id)
369 cmc.id,
370 cmc.user_id,
371 cmc.course_module_id,
372 cmc.grade,
373 cmc.passed,
374 cmc.completion_date,
375 cmc.needs_to_be_reviewed
376 FROM course_module_completions cmc
377 WHERE cmc.course_id = $1
378 AND cmc.deleted_at IS NULL
379 AND cmc.user_id = ANY($2::uuid[])
380 ORDER BY cmc.user_id, cmc.course_module_id, cmc.completion_date DESC
381),
382cmcr AS (
383 SELECT course_module_completion_id
384 FROM course_module_completion_registered_to_study_registries
385 WHERE course_id = $1
386 AND deleted_at IS NULL
387)
388SELECT
389 e.user_id AS "user_id!",
390 m.module_id AS "module_id!",
391 m.module_name AS "module?",
392 r.grade AS "grade?",
393 r.passed AS "passed?",
394 (r.id IS NOT NULL AND r.id IN (SELECT course_module_completion_id FROM cmcr)) AS "registered!",
395 COALESCE(r.needs_to_be_reviewed, false) AS "needs_to_be_reviewed!"
396FROM modules m
397CROSS JOIN targets e
398LEFT JOIN latest_cmc r
399 ON r.user_id = e.user_id
400 AND r.course_module_id = m.module_id
401ORDER BY m.order_number, e.user_id
402 "#,
403 course_id,
404 user_ids
405 )
406 .fetch_all(&mut *conn)
407 .await?;
408
409 Ok(rows)
410}
411
412#[derive(Clone, PartialEq, Deserialize, Serialize, sqlx::FromRow, ToSchema)]
413
414pub struct CertificateGridRow {
415 pub user_id: Uuid,
416 pub date_issued: Option<DateTime<Utc>>,
417 pub verification_id: Option<String>,
418 pub certificate_id: Option<Uuid>,
419 pub name_on_certificate: Option<String>,
420}
421
422pub async fn get_certificates_grid_for_users(
424 conn: &mut PgConnection,
425 course_id: Uuid,
426 user_ids: &[Uuid],
427) -> ModelResult<Vec<CertificateGridRow>> {
428 let rows = sqlx::query_as!(
429 CertificateGridRow,
430 r#"
431WITH targets AS (
432 SELECT DISTINCT user_id
433 FROM course_instance_enrollments
434 WHERE course_id = $1
435 AND deleted_at IS NULL
436 AND user_id = ANY($2::uuid[])
437),
438user_certs AS (
439 -- one latest certificate per user for this course
440 SELECT DISTINCT ON (gc.user_id)
441 gc.user_id,
442 gc.id,
443 gc.created_at AS latest_issued_at,
444 gc.verification_id,
445 gc.name_on_certificate
446 FROM generated_certificates gc
447 JOIN certificate_configuration_to_requirements cctr
448 ON gc.certificate_configuration_id = cctr.certificate_configuration_id
449 AND cctr.deleted_at IS NULL
450 JOIN course_modules cm
451 ON cm.id = cctr.course_module_id
452 AND cm.deleted_at IS NULL
453 WHERE cm.course_id = $1
454 AND gc.deleted_at IS NULL
455 AND gc.user_id = ANY($2::uuid[])
456 ORDER BY gc.user_id, gc.created_at DESC
457)
458SELECT
459 e.user_id AS "user_id!",
460 uc.latest_issued_at AS "date_issued?",
461 uc.verification_id AS "verification_id?",
462 uc.id AS "certificate_id?",
463 uc.name_on_certificate AS "name_on_certificate?"
464FROM targets e
465LEFT JOIN user_certs uc ON uc.user_id = e.user_id
466 "#,
467 course_id,
468 user_ids
469 )
470 .fetch_all(&mut *conn)
471 .await?;
472
473 Ok(rows)
474}
475
476#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
479
480pub struct CourseStudentsProgressStructure {
481 pub chapter_locking_enabled: bool,
482 pub chapters: Vec<DatabaseChapter>,
483 pub chapter_availability: Vec<ChapterAvailability>,
484}
485
486#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
488
489pub struct CourseStudentsProgressUsers {
490 pub user_chapter_progress: Vec<UserChapterProgress>,
491 pub user_chapter_locking_statuses: Vec<UserChapterLockingStatus>,
492}
493
494pub async fn get_progress_structure(
496 conn: &mut PgConnection,
497 course_id: Uuid,
498) -> ModelResult<CourseStudentsProgressStructure> {
499 let course = crate::courses::get_course(conn, course_id).await?;
500 let chapters = crate::chapters::get_course_chapters(conn, course_id).await?;
501 let chapter_availability = chapters::fetch_chapter_availability(conn, course_id).await?;
502
503 Ok(CourseStudentsProgressStructure {
504 chapter_locking_enabled: course.chapter_locking_enabled,
505 chapters,
506 chapter_availability,
507 })
508}
509
510pub async fn get_progress_for_users(
512 conn: &mut PgConnection,
513 course_id: Uuid,
514 user_ids: &[Uuid],
515) -> ModelResult<CourseStudentsProgressUsers> {
516 let course = crate::courses::get_course(conn, course_id).await?;
517 let user_chapter_progress =
518 chapters::fetch_user_chapter_progress(conn, course_id, Some(user_ids)).await?;
519 let user_chapter_locking_statuses =
520 crate::user_chapter_locking_statuses::get_for_users_and_course(conn, user_ids, &course)
521 .await?;
522
523 Ok(CourseStudentsProgressUsers {
524 user_chapter_progress,
525 user_chapter_locking_statuses,
526 })
527}