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 user_search = crate::user_details::UserListSearch::new(search.unwrap_or_default());
88 let grade_filter = module_id.and(grade);
91
92 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 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, pub module: Option<String>, pub grade: Option<i32>, pub passed: Option<bool>, pub registered: bool, pub needs_to_be_reviewed: bool,
348}
349
350pub 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
427pub 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#[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#[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
499pub 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
515pub 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}