1use std::collections::HashMap;
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 completions_can_be_registered_to_sisu: 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 mut tx = conn.begin().await?;
118 sqlx::query!(
121 "
122INSERT INTO course_instance_enrollments (user_id, course_id, course_instance_id)
123VALUES ($1, $2, $3)
124ON CONFLICT DO NOTHING
125",
126 enrollment.user_id,
127 enrollment.course_id,
128 enrollment.course_instance_id,
129 )
130 .execute(&mut *tx)
131 .await?;
132 let enrollment = sqlx::query_as!(
133 CourseInstanceEnrollment,
134 "
135UPDATE course_instance_enrollments
136SET deleted_at = NULL
137WHERE user_id = $1
138 AND course_id = $2
139 AND course_instance_id = $3
140RETURNING *
141",
142 enrollment.user_id,
143 enrollment.course_id,
144 enrollment.course_instance_id,
145 )
146 .fetch_one(&mut *tx)
147 .await?;
148 tx.commit().await?;
149 Ok(enrollment)
150}
151
152pub async fn insert_enrollment_and_set_as_current(
153 conn: &mut PgConnection,
154 new_enrollment: NewCourseInstanceEnrollment,
155) -> ModelResult<CourseInstanceEnrollment> {
156 let mut tx = conn.begin().await?;
157
158 let enrollment = insert_enrollment_if_it_doesnt_exist(&mut tx, new_enrollment).await?;
159 crate::user_course_settings::upsert_user_course_settings_for_enrollment(&mut tx, &enrollment)
160 .await?;
161 tx.commit().await?;
162
163 Ok(enrollment)
164}
165
166pub async fn get_by_user_and_course_instance_id(
167 conn: &mut PgConnection,
168 user_id: Uuid,
169 course_instance_id: Uuid,
170) -> ModelResult<CourseInstanceEnrollment> {
171 let res = sqlx::query_as!(
172 CourseInstanceEnrollment,
173 "
174SELECT *
175FROM course_instance_enrollments
176WHERE user_id = $1
177 AND course_instance_id = $2
178 AND deleted_at IS NULL
179 ",
180 user_id,
181 course_instance_id
182 )
183 .fetch_one(conn)
184 .await?;
185 Ok(res)
186}
187
188pub async fn get_by_user_id(
189 conn: &mut PgConnection,
190 user_id: Uuid,
191) -> ModelResult<Vec<CourseInstanceEnrollment>> {
192 let res = sqlx::query_as!(
193 CourseInstanceEnrollment,
194 "
195SELECT *
196FROM course_instance_enrollments
197WHERE user_id = $1
198 AND deleted_at IS NULL
199 ",
200 user_id
201 )
202 .fetch_all(conn)
203 .await?;
204 Ok(res)
205}
206
207pub async fn get_by_user_id_and_course_ids(
208 conn: &mut PgConnection,
209 user_id: Uuid,
210 course_ids: &[Uuid],
211) -> ModelResult<Vec<CourseInstanceEnrollment>> {
212 let res = sqlx::query_as!(
213 CourseInstanceEnrollment,
214 "
215SELECT *
216FROM course_instance_enrollments
217WHERE user_id = $1
218 AND course_id = ANY($2)
219 AND deleted_at IS NULL
220 ",
221 user_id,
222 course_ids
223 )
224 .fetch_all(conn)
225 .await?;
226 Ok(res)
227}
228
229pub async fn get_course_instance_enrollments_info_for_user(
230 conn: &mut PgConnection,
231 user_id: Uuid,
232) -> ModelResult<CourseInstanceEnrollmentsInfo> {
233 let course_instance_enrollments = get_by_user_id(conn, user_id).await?;
234
235 let course_instance_ids: Vec<Uuid> = course_instance_enrollments
236 .iter()
237 .map(|e| e.course_instance_id)
238 .collect();
239
240 let course_instances = crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
241
242 let course_ids: Vec<Uuid> = course_instances.iter().map(|e| e.course_id).collect();
243
244 let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
245
246 let course_module_completions =
247 crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
248
249 let user_course_settings =
251 crate::user_course_settings::get_all_by_user_and_multiple_current_courses(
252 conn,
253 &course_ids,
254 user_id,
255 )
256 .await?;
257
258 Ok(CourseInstanceEnrollmentsInfo {
259 course_instance_enrollments,
260 course_instances,
261 courses,
262 user_course_settings,
263 course_module_completions,
264 })
265}
266
267struct CourseEnrollmentRow {
268 course_id: Uuid,
269 first_enrolled_at: Option<DateTime<Utc>>,
270}
271
272pub async fn get_course_enrollments_info_for_user(
276 conn: &mut PgConnection,
277 user_id: Uuid,
278) -> ModelResult<CourseEnrollmentsInfo> {
279 let rows = sqlx::query_as!(
280 CourseEnrollmentRow,
281 "
282SELECT course_id, MIN(created_at) AS first_enrolled_at
283FROM course_instance_enrollments
284WHERE user_id = $1 AND deleted_at IS NULL
285GROUP BY course_id
286ORDER BY first_enrolled_at
287 ",
288 user_id
289 )
290 .fetch_all(&mut *conn)
291 .await?;
292
293 let course_ids: Vec<Uuid> = rows.iter().map(|r| r.course_id).collect();
294
295 let module_first_submission_rows = sqlx::query!(
300 r#"
301SELECT c.course_module_id AS "course_module_id!",
302 MIN(ess.created_at) AS "first_submission_at!"
303FROM exercise_slide_submissions ess
304JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
305JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
306WHERE ess.user_id = $1 AND ess.course_id = ANY($2) AND ess.deleted_at IS NULL
307GROUP BY c.course_module_id
308 "#,
309 user_id,
310 &course_ids
311 )
312 .fetch_all(&mut *conn)
313 .await?;
314 let first_submission_by_module: HashMap<Uuid, DateTime<Utc>> = module_first_submission_rows
315 .into_iter()
316 .map(|r| (r.course_module_id, r.first_submission_at))
317 .collect();
318
319 let module_exercise_count_rows = sqlx::query!(
323 r#"
324SELECT c.course_module_id AS "course_module_id!",
325 COUNT(*) AS "count!"
326FROM exercises e
327JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
328WHERE e.course_id = ANY($1) AND e.deleted_at IS NULL
329GROUP BY c.course_module_id
330 "#,
331 &course_ids
332 )
333 .fetch_all(&mut *conn)
334 .await?;
335 let exercise_count_by_module: HashMap<Uuid, i64> = module_exercise_count_rows
336 .into_iter()
337 .map(|r| (r.course_module_id, r.count))
338 .collect();
339
340 let module_daily_submission_rows = sqlx::query!(
345 r#"
346SELECT c.course_module_id AS "course_module_id!",
347 DATE_TRUNC('day', ess.created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS "day!",
348 COUNT(*) AS "count!"
349FROM exercise_slide_submissions ess
350JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
351JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
352WHERE ess.user_id = $1 AND ess.course_id = ANY($2) AND ess.deleted_at IS NULL
353GROUP BY c.course_module_id, DATE_TRUNC('day', ess.created_at AT TIME ZONE 'UTC')
354ORDER BY c.course_module_id, "day!"
355 "#,
356 user_id,
357 &course_ids
358 )
359 .fetch_all(&mut *conn)
360 .await?;
361 let mut daily_submissions_by_module: HashMap<Uuid, Vec<DailySubmissionCount>> = HashMap::new();
362 for r in module_daily_submission_rows {
363 daily_submissions_by_module
364 .entry(r.course_module_id)
365 .or_default()
366 .push(DailySubmissionCount {
367 day: r.day,
368 count: r.count as i32,
369 });
370 }
371
372 let course_instance_enrollments = get_by_user_id(&mut *conn, user_id).await?;
373 let all_course_module_completions =
374 crate::course_module_completions::get_all_by_user_id(conn, user_id).await?;
375 let user_course_settings =
376 crate::user_course_settings::get_all_by_user_id(conn, user_id).await?;
377 let courses = crate::courses::get_by_ids(conn, &course_ids).await?;
378 let course_instance_ids: Vec<Uuid> = course_instance_enrollments
379 .iter()
380 .map(|e| e.course_instance_id)
381 .collect();
382 let all_course_instances =
383 crate::course_instances::get_by_ids(conn, &course_instance_ids).await?;
384 let all_course_modules = crate::course_modules::get_by_course_ids(conn, &course_ids).await?;
385
386 let mut course_enrollments = Vec::with_capacity(rows.len());
387 for row in rows {
388 let Some(course) = courses.iter().find(|c| c.id == row.course_id).cloned() else {
391 warn!(
392 user_id = %user_id,
393 course_id = %row.course_id,
394 "Skipping enrollment because its course has been deleted"
395 );
396 continue;
397 };
398 let course_instances: Vec<_> = all_course_instances
399 .iter()
400 .filter(|ci| ci.course_id == row.course_id)
401 .cloned()
402 .collect();
403 let user_course_settings_for_course = user_course_settings
404 .iter()
405 .find(|ucs| ucs.course_language_group_id == course.course_language_group_id)
406 .cloned();
407 let course_modules = all_course_modules
408 .iter()
409 .filter(|m| m.course_id == row.course_id)
410 .map(|m| CourseModuleInfo {
411 id: m.id,
412 name: m.name.clone(),
413 order_number: m.order_number,
414 first_submission_at: first_submission_by_module.get(&m.id).copied(),
415 exercise_count: exercise_count_by_module.get(&m.id).copied().unwrap_or(0) as i32,
416 daily_submissions: daily_submissions_by_module
417 .get(&m.id)
418 .cloned()
419 .unwrap_or_default(),
420 ects_credits: m.ects_credits,
421 uh_course_code: m.uh_course_code.clone(),
422 completions_can_be_registered_to_sisu: m.completions_can_be_registered_to_sisu,
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}