1use std::collections::{HashMap, HashSet};
2
3use crate::{
4 course_instances::CourseInstance, course_module_completions::CourseModuleCompletion,
5 courses::Course, prelude::*, user_course_settings::UserCourseSettings,
6};
7use utoipa::ToSchema;
8
9#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
10
11pub struct CourseInstanceEnrollment {
12 pub user_id: Uuid,
13 pub course_id: Uuid,
14 pub course_instance_id: Uuid,
15 pub created_at: DateTime<Utc>,
16 pub updated_at: DateTime<Utc>,
17 pub deleted_at: Option<DateTime<Utc>>,
18}
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
21
22pub struct CourseInstanceEnrollmentsInfo {
23 pub course_instance_enrollments: Vec<CourseInstanceEnrollment>,
24 pub course_instances: Vec<CourseInstance>,
25 pub courses: Vec<Course>,
26 pub user_course_settings: Vec<UserCourseSettings>,
27 pub course_module_completions: Vec<CourseModuleCompletion>,
28}
29
30#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
33pub struct DailySubmissionCount {
34 pub day: DateTime<Utc>,
35 pub count: i32,
36}
37
38#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
41pub struct CourseModuleInfo {
42 pub id: Uuid,
43 pub name: Option<String>,
44 pub order_number: i32,
45 pub first_submission_at: Option<DateTime<Utc>>,
48 pub exercise_count: i32,
51 pub daily_submissions: Vec<DailySubmissionCount>,
53 pub ects_credits: Option<f32>,
55 pub uh_course_code: Option<String>,
57 pub enable_credit_registration_via_suotar: bool,
58}
59
60#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
61
62pub struct CourseEnrollmentInfo {
63 pub course_id: Uuid,
64 pub course: Course,
65 pub course_instances: Vec<CourseInstance>,
66 pub user_course_settings: Option<UserCourseSettings>,
67 pub course_modules: Vec<CourseModuleInfo>,
69 pub course_module_completions: Vec<CourseModuleCompletion>,
70 pub course_module_completions_needing_review: i32,
71 pub first_enrolled_at: DateTime<Utc>,
72 pub is_current: bool,
73}
74
75#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
76
77pub struct CourseEnrollmentsInfo {
78 pub course_enrollments: Vec<CourseEnrollmentInfo>,
79}
80
81pub async fn insert(
82 conn: &mut PgConnection,
83 user_id: Uuid,
84 course_id: Uuid,
85 course_instance_id: Uuid,
86) -> ModelResult<()> {
87 sqlx::query!(
88 "
89INSERT INTO course_instance_enrollments (user_id, course_id, course_instance_id)
90VALUES ($1, $2, $3)
91",
92 user_id,
93 course_id,
94 course_instance_id,
95 )
96 .execute(conn)
97 .await?;
98 Ok(())
99}
100
101#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
102pub struct NewCourseInstanceEnrollment {
103 pub user_id: Uuid,
104 pub course_id: Uuid,
105 pub course_instance_id: Uuid,
106}
107
108pub async fn insert_enrollment_if_it_doesnt_exist(
114 conn: &mut PgConnection,
115 enrollment: NewCourseInstanceEnrollment,
116) -> ModelResult<CourseInstanceEnrollment> {
117 let enrollment = sqlx::query_as!(
118 CourseInstanceEnrollment,
119 "
120INSERT INTO course_instance_enrollments (user_id, course_id, course_instance_id)
121VALUES ($1, $2, $3)
122ON CONFLICT (user_id, course_id, course_instance_id)
123DO UPDATE SET deleted_at = NULL
124RETURNING *;
125",
126 enrollment.user_id,
127 enrollment.course_id,
128 enrollment.course_instance_id,
129 )
130 .fetch_one(conn)
131 .await?;
132 Ok(enrollment)
133}
134
135pub async fn insert_enrollment_and_set_as_current(
136 conn: &mut PgConnection,
137 new_enrollment: NewCourseInstanceEnrollment,
138) -> ModelResult<CourseInstanceEnrollment> {
139 let mut tx = conn.begin().await?;
140
141 let enrollment = insert_enrollment_if_it_doesnt_exist(&mut tx, new_enrollment).await?;
142 crate::user_course_settings::upsert_user_course_settings_for_enrollment(&mut tx, &enrollment)
143 .await?;
144 tx.commit().await?;
145
146 Ok(enrollment)
147}
148
149pub async fn get_by_user_and_course_instance_id(
150 conn: &mut PgConnection,
151 user_id: Uuid,
152 course_instance_id: Uuid,
153) -> ModelResult<CourseInstanceEnrollment> {
154 let res = sqlx::query_as!(
155 CourseInstanceEnrollment,
156 "
157SELECT *
158FROM course_instance_enrollments
159WHERE user_id = $1
160 AND course_instance_id = $2
161 AND deleted_at IS NULL
162 ",
163 user_id,
164 course_instance_id
165 )
166 .fetch_one(conn)
167 .await?;
168 Ok(res)
169}
170
171pub async fn get_by_user_id(
172 conn: &mut PgConnection,
173 user_id: Uuid,
174) -> ModelResult<Vec<CourseInstanceEnrollment>> {
175 let res = sqlx::query_as!(
176 CourseInstanceEnrollment,
177 "
178SELECT *
179FROM course_instance_enrollments
180WHERE user_id = $1
181 AND deleted_at IS NULL
182 ",
183 user_id
184 )
185 .fetch_all(conn)
186 .await?;
187 Ok(res)
188}
189
190pub async fn get_by_user_id_and_course_ids(
191 conn: &mut PgConnection,
192 user_id: Uuid,
193 course_ids: &[Uuid],
194) -> ModelResult<Vec<CourseInstanceEnrollment>> {
195 let res = sqlx::query_as!(
196 CourseInstanceEnrollment,
197 "
198SELECT *
199FROM course_instance_enrollments
200WHERE user_id = $1
201 AND course_id = ANY($2)
202 AND deleted_at IS NULL
203 ",
204 user_id,
205 course_ids
206 )
207 .fetch_all(conn)
208 .await?;
209 Ok(res)
210}
211
212pub async fn get_course_instance_enrollments_info_for_user(
213 conn: &mut PgConnection,
214 user_id: Uuid,
215) -> ModelResult<CourseInstanceEnrollmentsInfo> {
216 let course_instance_enrollments = get_by_user_id(conn, user_id).await?;
217
218 let course_instance_ids: Vec<Uuid> = course_instance_enrollments
219 .iter()
220 .map(|e| e.course_instance_id)
221 .collect();
222
223 let course_instances = crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
224
225 let course_ids: Vec<Uuid> = course_instances.iter().map(|e| e.course_id).collect();
226
227 let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
228
229 let course_module_completions =
230 crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
231
232 let user_course_settings =
234 crate::user_course_settings::get_all_by_user_and_multiple_current_courses(
235 conn,
236 &course_ids,
237 user_id,
238 )
239 .await?;
240
241 Ok(CourseInstanceEnrollmentsInfo {
242 course_instance_enrollments,
243 course_instances,
244 courses,
245 user_course_settings,
246 course_module_completions,
247 })
248}
249
250struct CourseEnrollmentRow {
251 course_id: Uuid,
252 first_enrolled_at: Option<DateTime<Utc>>,
253}
254
255pub async fn get_course_enrollments_info_for_user(
259 conn: &mut PgConnection,
260 user_id: Uuid,
261) -> ModelResult<CourseEnrollmentsInfo> {
262 let rows = sqlx::query_as!(
263 CourseEnrollmentRow,
264 "
265SELECT course_id, MIN(created_at) AS first_enrolled_at
266FROM course_instance_enrollments
267WHERE user_id = $1 AND deleted_at IS NULL
268GROUP BY course_id
269ORDER BY first_enrolled_at
270 ",
271 user_id
272 )
273 .fetch_all(&mut *conn)
274 .await?;
275
276 let course_ids: Vec<Uuid> = rows.iter().map(|r| r.course_id).collect();
277
278 let module_first_submission_rows = sqlx::query!(
283 r#"
284SELECT c.course_module_id AS "course_module_id!",
285 MIN(ess.created_at) AS "first_submission_at!"
286FROM exercise_slide_submissions ess
287JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
288JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
289WHERE ess.user_id = $1 AND ess.course_id = ANY($2) AND ess.deleted_at IS NULL
290GROUP BY c.course_module_id
291 "#,
292 user_id,
293 &course_ids
294 )
295 .fetch_all(&mut *conn)
296 .await?;
297 let first_submission_by_module: HashMap<Uuid, DateTime<Utc>> = module_first_submission_rows
298 .into_iter()
299 .map(|r| (r.course_module_id, r.first_submission_at))
300 .collect();
301
302 let module_exercise_count_rows = sqlx::query!(
306 r#"
307SELECT c.course_module_id AS "course_module_id!",
308 COUNT(*) AS "count!"
309FROM exercises e
310JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
311WHERE e.course_id = ANY($1) AND e.deleted_at IS NULL
312GROUP BY c.course_module_id
313 "#,
314 &course_ids
315 )
316 .fetch_all(&mut *conn)
317 .await?;
318 let exercise_count_by_module: HashMap<Uuid, i64> = module_exercise_count_rows
319 .into_iter()
320 .map(|r| (r.course_module_id, r.count))
321 .collect();
322
323 let module_daily_submission_rows = sqlx::query!(
328 r#"
329SELECT c.course_module_id AS "course_module_id!",
330 DATE_TRUNC('day', ess.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS "day!",
331 COUNT(*) AS "count!"
332FROM exercise_slide_submissions ess
333JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
334JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
335WHERE ess.user_id = $1 AND ess.course_id = ANY($2) AND ess.deleted_at IS NULL
336GROUP BY c.course_module_id, DATE_TRUNC('day', ess.created_at AT TIME ZONE 'UTC')
337ORDER BY c.course_module_id, "day!"
338 "#,
339 user_id,
340 &course_ids
341 )
342 .fetch_all(&mut *conn)
343 .await?;
344 let mut daily_submissions_by_module: HashMap<Uuid, Vec<DailySubmissionCount>> = HashMap::new();
345 for r in module_daily_submission_rows {
346 daily_submissions_by_module
347 .entry(r.course_module_id)
348 .or_default()
349 .push(DailySubmissionCount {
350 day: r.day,
351 count: r.count as i32,
352 });
353 }
354
355 let credit_registration_enabled_module_ids: HashSet<Uuid> = sqlx::query_scalar!(
357 "
358SELECT id
359FROM course_modules
360WHERE course_id = ANY($1)
361 AND enable_credit_registration_via_suotar
362 AND deleted_at IS NULL
363 ",
364 &course_ids
365 )
366 .fetch_all(&mut *conn)
367 .await?
368 .into_iter()
369 .collect();
370
371 let course_instance_enrollments = get_by_user_id(&mut *conn, user_id).await?;
372 let all_course_module_completions =
373 crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
374 let user_course_settings =
375 crate::user_course_settings::get_all_by_user_id(conn, user_id).await?;
376 let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
377 let course_instance_ids: Vec<Uuid> = course_instance_enrollments
378 .iter()
379 .map(|e| e.course_instance_id)
380 .collect();
381 let all_course_instances =
382 crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
383 let all_course_modules = crate::course_modules::get_by_course_ids(conn, &course_ids).await?;
384
385 let mut course_enrollments = Vec::with_capacity(rows.len());
386 for row in rows {
387 let Some(course) = courses.iter().find(|c| c.id == row.course_id).cloned() else {
390 warn!(
391 user_id = %user_id,
392 course_id = %row.course_id,
393 "Skipping enrollment because its course has been deleted"
394 );
395 continue;
396 };
397 let course_instances: Vec<_> = all_course_instances
398 .iter()
399 .filter(|ci| ci.course_id == row.course_id)
400 .cloned()
401 .collect();
402 let user_course_settings_for_course = user_course_settings
403 .iter()
404 .find(|ucs| ucs.course_language_group_id == course.course_language_group_id)
405 .cloned();
406 let course_modules = all_course_modules
407 .iter()
408 .filter(|m| m.course_id == row.course_id)
409 .map(|m| CourseModuleInfo {
410 id: m.id,
411 name: m.name.clone(),
412 order_number: m.order_number,
413 first_submission_at: first_submission_by_module.get(&m.id).copied(),
414 exercise_count: exercise_count_by_module.get(&m.id).copied().unwrap_or(0) as i32,
415 daily_submissions: daily_submissions_by_module
416 .get(&m.id)
417 .cloned()
418 .unwrap_or_default(),
419 ects_credits: m.ects_credits,
420 uh_course_code: m.uh_course_code.clone(),
421 enable_credit_registration_via_suotar: credit_registration_enabled_module_ids
422 .contains(&m.id),
423 })
424 .collect();
425 let course_module_completions = all_course_module_completions
426 .iter()
427 .filter(|cmc| cmc.course_id == row.course_id)
428 .cloned()
429 .collect();
430 let course_module_completions_needing_review = all_course_module_completions
431 .iter()
432 .filter(|cmc| cmc.course_id == row.course_id && cmc.needs_to_be_reviewed)
433 .count() as i32;
434 let is_current = user_course_settings_for_course
435 .as_ref()
436 .map(|ucs| ucs.current_course_id == row.course_id)
437 .unwrap_or(false);
438
439 let first_enrolled_at = row.first_enrolled_at.ok_or_else(|| {
440 crate::ModelError::new(
441 crate::error::ModelErrorType::Generic,
442 "first_enrolled_at missing for grouped enrollment row".to_string(),
443 None,
444 )
445 })?;
446
447 course_enrollments.push(CourseEnrollmentInfo {
448 course_id: row.course_id,
449 course,
450 course_instances,
451 user_course_settings: user_course_settings_for_course,
452 course_modules,
453 course_module_completions,
454 course_module_completions_needing_review,
455 first_enrolled_at,
456 is_current,
457 });
458 }
459
460 Ok(CourseEnrollmentsInfo { course_enrollments })
461}