1use std::collections::HashMap;
2
3use utoipa::ToSchema;
4
5use crate::{chapters, error::missing_model_error, prelude::*};
6
7struct CourseModulesSchema {
10 id: Uuid,
11 created_at: DateTime<Utc>,
12 updated_at: DateTime<Utc>,
13 deleted_at: Option<DateTime<Utc>>,
14 name: Option<String>,
15 course_id: Uuid,
16 order_number: i32,
17 copied_from: Option<Uuid>,
18 uh_course_code: Option<String>,
19 automatic_completion: bool,
20 automatic_completion_number_of_exercises_attempted_treshold: Option<i32>,
21 automatic_completion_number_of_points_treshold: Option<i32>,
22 automatic_completion_requires_exam: bool,
23 completion_registration_link_override: Option<String>,
24 ects_credits: Option<f32>,
25 enable_registering_completion_to_uh_open_university: bool,
26 certification_enabled: bool,
27 enable_credit_registration_via_suotar: bool,
28}
29#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
32pub struct CourseModule {
33 pub id: Uuid,
34 pub created_at: DateTime<Utc>,
35 pub updated_at: DateTime<Utc>,
36 pub deleted_at: Option<DateTime<Utc>>,
37 pub name: Option<String>,
38 pub course_id: Uuid,
39 pub order_number: i32,
40 pub copied_from: Option<Uuid>,
41 pub uh_course_code: Option<String>,
42 pub completion_policy: CompletionPolicy,
43 pub completion_registration_link_override: Option<String>,
45 pub ects_credits: Option<f32>,
46 pub enable_registering_completion_to_uh_open_university: bool,
47 pub certification_enabled: bool,
48 pub enable_credit_registration_via_suotar: bool,
49}
50
51impl CourseModule {
52 pub fn new(id: Uuid, course_id: Uuid) -> Self {
53 Self {
54 id,
55 created_at: Utc::now(),
56 updated_at: Utc::now(),
57 deleted_at: None,
58 name: None,
59 course_id,
60 order_number: 0,
61 copied_from: None,
62 uh_course_code: None,
63 completion_policy: CompletionPolicy::Manual,
64 completion_registration_link_override: None,
65 ects_credits: None,
66 enable_registering_completion_to_uh_open_university: false,
67 certification_enabled: false,
68 enable_credit_registration_via_suotar: false,
69 }
70 }
71 pub fn set_timestamps(
72 mut self,
73 created_at: DateTime<Utc>,
74 updated_at: DateTime<Utc>,
75 deleted_at: Option<DateTime<Utc>>,
76 ) -> Self {
77 self.created_at = created_at;
78 self.updated_at = updated_at;
79 self.deleted_at = deleted_at;
80 self
81 }
82
83 pub fn set_name_and_order_number(mut self, name: Option<String>, order_number: i32) -> Self {
85 self.name = name;
86 self.order_number = order_number;
87 self
88 }
89
90 pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
91 self.completion_policy = completion_policy;
92 self
93 }
94
95 pub fn set_registration_info(
96 mut self,
97 uh_course_code: Option<String>,
98 ects_credits: Option<f32>,
99 completion_registration_link_override: Option<String>,
100 enable_registering_completion_to_uh_open_university: bool,
101 enable_credit_registration_via_suotar: bool,
102 ) -> Self {
103 self.uh_course_code = uh_course_code;
104 self.ects_credits = ects_credits;
105 self.completion_registration_link_override = completion_registration_link_override;
106 self.enable_registering_completion_to_uh_open_university =
107 enable_registering_completion_to_uh_open_university;
108 self.enable_credit_registration_via_suotar = enable_credit_registration_via_suotar;
109 self
110 }
111
112 pub fn set_certification_enabled(mut self, certification_enabled: bool) -> Self {
113 self.certification_enabled = certification_enabled;
114 self
115 }
116
117 pub fn is_default_module(&self) -> bool {
118 self.name.is_none()
119 }
120}
121
122impl From<CourseModulesSchema> for CourseModule {
123 fn from(schema: CourseModulesSchema) -> Self {
124 let completion_policy = if schema.automatic_completion {
125 CompletionPolicy::Automatic(AutomaticCompletionRequirements {
126 course_module_id: schema.id,
127 number_of_exercises_attempted_treshold: schema
128 .automatic_completion_number_of_exercises_attempted_treshold,
129 number_of_points_treshold: schema.automatic_completion_number_of_points_treshold,
130 requires_exam: schema.automatic_completion_requires_exam,
131 })
132 } else {
133 CompletionPolicy::Manual
134 };
135 Self {
136 id: schema.id,
137 created_at: schema.created_at,
138 updated_at: schema.updated_at,
139 deleted_at: schema.deleted_at,
140 name: schema.name,
141 course_id: schema.course_id,
142 order_number: schema.order_number,
143 copied_from: schema.copied_from,
144 uh_course_code: schema.uh_course_code,
145 completion_policy,
146 completion_registration_link_override: schema.completion_registration_link_override,
147 ects_credits: schema.ects_credits,
148 enable_registering_completion_to_uh_open_university: schema
149 .enable_registering_completion_to_uh_open_university,
150 certification_enabled: schema.certification_enabled,
151 enable_credit_registration_via_suotar: schema.enable_credit_registration_via_suotar,
152 }
153 }
154}
155
156#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
157pub struct NewCourseModule {
158 completion_policy: CompletionPolicy,
159 completion_registration_link_override: Option<String>,
160 course_id: Uuid,
161 ects_credits: Option<f32>,
162 name: Option<String>,
163 order_number: i32,
164 uh_course_code: Option<String>,
165 enable_registering_completion_to_uh_open_university: bool,
166 enable_credit_registration_via_suotar: bool,
167}
168
169impl NewCourseModule {
170 pub fn new(course_id: Uuid, name: Option<String>, order_number: i32) -> Self {
171 Self {
172 completion_policy: CompletionPolicy::Manual,
173 completion_registration_link_override: None,
174 course_id,
175 ects_credits: None,
176 name,
177 order_number,
178 uh_course_code: None,
179 enable_registering_completion_to_uh_open_university: false,
180 enable_credit_registration_via_suotar: false,
181 }
182 }
183
184 pub fn new_course_default(course_id: Uuid) -> Self {
185 Self::new(course_id, None, 0)
186 }
187
188 pub fn set_uh_course_code(mut self, uh_course_code: Option<String>) -> Self {
189 self.uh_course_code = uh_course_code;
190 self
191 }
192
193 pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
194 self.completion_policy = completion_policy;
195 self
196 }
197
198 pub fn set_completion_registration_link_override(
199 mut self,
200 completion_registration_link_override: Option<String>,
201 ) -> Self {
202 self.completion_registration_link_override = completion_registration_link_override;
203 self
204 }
205
206 pub fn set_ects_credits(mut self, ects_credits: Option<f32>) -> Self {
207 self.ects_credits = ects_credits;
208 self
209 }
210
211 pub fn set_enable_registering_completion_to_uh_open_university(
212 mut self,
213 enable_registering_completion_to_uh_open_university: bool,
214 ) -> Self {
215 self.enable_registering_completion_to_uh_open_university =
216 enable_registering_completion_to_uh_open_university;
217 self
218 }
219
220 pub fn set_enable_credit_registration_via_suotar(
221 mut self,
222 enable_credit_registration_via_suotar: bool,
223 ) -> Self {
224 self.enable_credit_registration_via_suotar = enable_credit_registration_via_suotar;
225 self
226 }
227}
228
229#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
230pub struct NewModule {
231 name: String,
232 order_number: i32,
233 chapters: Vec<Uuid>,
234 uh_course_code: Option<String>,
235 ects_credits: Option<f32>,
236 completion_policy: CompletionPolicy,
237 completion_registration_link_override: Option<String>,
238 enable_registering_completion_to_uh_open_university: bool,
239 enable_credit_registration_via_suotar: bool,
240}
241
242#[derive(Debug, Deserialize, ToSchema)]
243pub struct ModifiedModule {
244 id: Uuid,
245 name: Option<String>,
246 order_number: i32,
247 uh_course_code: Option<String>,
248 ects_credits: Option<f32>,
249 completion_policy: CompletionPolicy,
250 completion_registration_link_override: Option<String>,
251 enable_registering_completion_to_uh_open_university: bool,
252 enable_credit_registration_via_suotar: bool,
253}
254
255#[derive(Debug, Deserialize, ToSchema)]
256pub struct CourseAuditingModuleUpdate {
257 pub id: Uuid,
258 pub name: Option<String>,
259 pub order_number: i32,
260 pub uh_course_code: Option<String>,
261 pub ects_credits: Option<f32>,
262 pub completion_registration_link_override: Option<String>,
263 pub enable_registering_completion_to_uh_open_university: bool,
264}
265
266#[derive(Debug, Deserialize, ToSchema)]
267pub struct ModuleUpdates {
268 new_modules: Vec<NewModule>,
269 deleted_modules: Vec<Uuid>,
270 modified_modules: Vec<ModifiedModule>,
271 moved_chapters: Vec<(Uuid, Uuid)>,
272}
273
274pub struct ModuleSizeCounts {
277 pub chapters: i64,
278 pub exercises: i64,
279}
280
281#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
285pub struct CourseModuleCreditRegistrationConfig {
286 pub course_module_id: Uuid,
287 pub course_id: Uuid,
288 pub enable_credit_registration_via_suotar: bool,
289 pub uh_course_code: Option<String>,
290 pub ects_credits: Option<f32>,
291 pub credit_registration_paused_at: Option<DateTime<Utc>>,
292 pub credit_registration_paused_by_user_id: Option<Uuid>,
293 pub credit_registration_pause_reason: Option<String>,
294 pub credit_registration_config_checked_at: Option<DateTime<Utc>>,
295 pub credit_registration_course_code_allowed: Option<bool>,
297 pub credit_registration_config_check_message: Option<String>,
298 pub credit_registration_has_enrolment_link: bool,
301}
302
303#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
304pub struct AutomaticCompletionRequirements {
305 pub course_module_id: Uuid,
307 pub number_of_exercises_attempted_treshold: Option<i32>,
308 pub number_of_points_treshold: Option<i32>,
309 pub requires_exam: bool,
310}
311
312impl AutomaticCompletionRequirements {
313 pub fn passes_exercise_tresholds(
316 &self,
317 exercises_attempted: i32,
318 exercise_points: i32,
319 ) -> bool {
320 self.passes_number_of_exercises_attempted_treshold(exercises_attempted)
321 && self.passes_number_of_exercise_points_treshold(exercise_points)
322 }
323
324 pub fn passes_number_of_exercises_attempted_treshold(&self, exercises_attempted: i32) -> bool {
327 self.number_of_exercises_attempted_treshold
328 .map(|x| x <= exercises_attempted)
329 .unwrap_or(true)
330 }
331
332 pub fn passes_number_of_exercise_points_treshold(&self, exercise_points: i32) -> bool {
335 self.number_of_points_treshold
336 .map(|x| x <= exercise_points)
337 .unwrap_or(true)
338 }
339}
340
341#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)]
342#[serde(tag = "policy", rename_all = "kebab-case")]
343pub enum CompletionPolicy {
344 Automatic(AutomaticCompletionRequirements),
345 Manual,
346}
347
348impl CompletionPolicy {
349 pub fn automatic(&self) -> Option<&AutomaticCompletionRequirements> {
351 match self {
352 CompletionPolicy::Automatic(requirements) => Some(requirements),
353 CompletionPolicy::Manual => None,
354 }
355 }
356
357 fn to_database_fields(&self) -> (bool, Option<i32>, Option<i32>, bool) {
358 match self {
359 CompletionPolicy::Automatic(requirements) => (
360 true,
361 requirements.number_of_exercises_attempted_treshold,
362 requirements.number_of_points_treshold,
363 requirements.requires_exam,
364 ),
365 CompletionPolicy::Manual => (false, None, None, false),
366 }
367 }
368}
369
370pub async fn insert(
371 conn: &mut PgConnection,
372 pkey_policy: PKeyPolicy<Uuid>,
373 new_course_module: &NewCourseModule,
374) -> ModelResult<CourseModule> {
375 let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
376 new_course_module.completion_policy.to_database_fields();
377 let res = sqlx::query_as!(
378 CourseModulesSchema,
379 "
380INSERT INTO course_modules (
381 id,
382 course_id,
383 name,
384 order_number,
385 automatic_completion,
386 automatic_completion_number_of_exercises_attempted_treshold,
387 automatic_completion_number_of_points_treshold,
388 automatic_completion_requires_exam,
389 ects_credits,
390 enable_registering_completion_to_uh_open_university,
391 uh_course_code,
392 enable_credit_registration_via_suotar,
393 completion_registration_link_override
394 )
395VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
396RETURNING id,
397 created_at,
398 updated_at,
399 deleted_at,
400 name,
401 course_id,
402 order_number,
403 copied_from,
404 uh_course_code,
405 automatic_completion,
406 automatic_completion_number_of_exercises_attempted_treshold,
407 automatic_completion_number_of_points_treshold,
408 automatic_completion_requires_exam,
409 completion_registration_link_override,
410 ects_credits,
411 enable_registering_completion_to_uh_open_university,
412 certification_enabled,
413 enable_credit_registration_via_suotar
414 ",
415 pkey_policy.into_uuid(),
416 new_course_module.course_id,
417 new_course_module.name,
418 new_course_module.order_number,
419 automatic_completion,
420 exercises_treshold,
421 points_treshold,
422 requires_exam,
423 new_course_module.ects_credits,
424 new_course_module.enable_registering_completion_to_uh_open_university,
425 new_course_module.uh_course_code,
426 new_course_module.enable_credit_registration_via_suotar,
427 new_course_module.completion_registration_link_override,
428 )
429 .fetch_one(conn)
430 .await?;
431 Ok(res.into())
432}
433
434pub async fn rename(conn: &mut PgConnection, id: Uuid, name: &str) -> ModelResult<()> {
435 sqlx::query!(
436 "
437UPDATE course_modules
438SET name = $1
439WHERE id = $2
440",
441 name,
442 id
443 )
444 .execute(conn)
445 .await?;
446 Ok(())
447}
448
449pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
450 let associated_chapters = chapters::get_for_module(conn, id).await?;
451 if !associated_chapters.is_empty() {
452 return Err(ModelError::new(
453 ModelErrorType::InvalidRequest,
454 format!(
455 "Cannot remove module {id} because it has {} chapters associated with it",
456 associated_chapters.len()
457 ),
458 None,
459 ));
460 }
461 sqlx::query!(
462 "
463UPDATE course_modules
464SET deleted_at = now()
465WHERE id = $1
466AND deleted_at IS NULL
467",
468 id
469 )
470 .execute(conn)
471 .await?;
472 Ok(())
473}
474
475pub async fn get_all_modules(conn: &mut PgConnection) -> ModelResult<Vec<CourseModule>> {
476 let res = sqlx::query_as!(
477 CourseModulesSchema,
478 "
479SELECT id,
480 created_at,
481 updated_at,
482 deleted_at,
483 name,
484 course_id,
485 order_number,
486 copied_from,
487 uh_course_code,
488 automatic_completion,
489 automatic_completion_number_of_exercises_attempted_treshold,
490 automatic_completion_number_of_points_treshold,
491 automatic_completion_requires_exam,
492 completion_registration_link_override,
493 ects_credits,
494 enable_registering_completion_to_uh_open_university,
495 certification_enabled,
496 enable_credit_registration_via_suotar
497FROM course_modules
498WHERE deleted_at IS NULL
499 ",
500 )
501 .map(|x| x.into())
502 .fetch_all(conn)
503 .await?;
504 Ok(res)
505}
506
507pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModule> {
508 let res = sqlx::query_as!(
509 CourseModulesSchema,
510 "
511SELECT id,
512 created_at,
513 updated_at,
514 deleted_at,
515 name,
516 course_id,
517 order_number,
518 copied_from,
519 uh_course_code,
520 automatic_completion,
521 automatic_completion_number_of_exercises_attempted_treshold,
522 automatic_completion_number_of_points_treshold,
523 automatic_completion_requires_exam,
524 completion_registration_link_override,
525 ects_credits,
526 enable_registering_completion_to_uh_open_university,
527 certification_enabled,
528 enable_credit_registration_via_suotar
529FROM course_modules
530WHERE id = $1
531 AND deleted_at IS NULL
532 ",
533 id,
534 )
535 .fetch_one(conn)
536 .await?;
537 Ok(res.into())
538}
539
540pub async fn get_by_ids(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<Vec<CourseModule>> {
541 let res = sqlx::query_as!(
542 CourseModulesSchema,
543 "
544SELECT id,
545 created_at,
546 updated_at,
547 deleted_at,
548 name,
549 course_id,
550 order_number,
551 copied_from,
552 uh_course_code,
553 automatic_completion,
554 automatic_completion_number_of_exercises_attempted_treshold,
555 automatic_completion_number_of_points_treshold,
556 automatic_completion_requires_exam,
557 completion_registration_link_override,
558 ects_credits,
559 enable_registering_completion_to_uh_open_university,
560 certification_enabled,
561 enable_credit_registration_via_suotar
562FROM course_modules
563WHERE id = ANY($1)
564 AND deleted_at IS NULL
565 ",
566 ids,
567 )
568 .map(|x| x.into())
569 .fetch_all(conn)
570 .await?;
571 Ok(res)
572}
573
574pub async fn get_by_course_id(
575 conn: &mut PgConnection,
576 course_id: Uuid,
577) -> ModelResult<Vec<CourseModule>> {
578 let res = sqlx::query_as!(
579 CourseModulesSchema,
580 "
581SELECT id,
582 created_at,
583 updated_at,
584 deleted_at,
585 name,
586 course_id,
587 order_number,
588 copied_from,
589 uh_course_code,
590 automatic_completion,
591 automatic_completion_number_of_exercises_attempted_treshold,
592 automatic_completion_number_of_points_treshold,
593 automatic_completion_requires_exam,
594 completion_registration_link_override,
595 ects_credits,
596 enable_registering_completion_to_uh_open_university,
597 certification_enabled,
598 enable_credit_registration_via_suotar
599FROM course_modules
600WHERE course_id = $1
601AND deleted_at IS NULL
602",
603 course_id
604 )
605 .map(|x| x.into())
606 .fetch_all(conn)
607 .await?;
608 Ok(res)
609}
610
611pub async fn get_by_course_ids(
614 conn: &mut PgConnection,
615 course_ids: &[Uuid],
616) -> ModelResult<Vec<CourseModule>> {
617 let res = sqlx::query_as!(
618 CourseModulesSchema,
619 "
620SELECT id,
621 created_at,
622 updated_at,
623 deleted_at,
624 name,
625 course_id,
626 order_number,
627 copied_from,
628 uh_course_code,
629 automatic_completion,
630 automatic_completion_number_of_exercises_attempted_treshold,
631 automatic_completion_number_of_points_treshold,
632 automatic_completion_requires_exam,
633 completion_registration_link_override,
634 ects_credits,
635 enable_registering_completion_to_uh_open_university,
636 certification_enabled,
637 enable_credit_registration_via_suotar
638FROM course_modules
639WHERE course_id = ANY($1)
640AND deleted_at IS NULL
641",
642 course_ids
643 )
644 .map(|x| x.into())
645 .fetch_all(conn)
646 .await?;
647 Ok(res)
648}
649
650pub async fn get_by_course_id_only_with_open_chapters(
651 conn: &mut PgConnection,
652 course_id: Uuid,
653) -> ModelResult<Vec<CourseModule>> {
654 let res = sqlx::query_as!(
655 CourseModulesSchema,
656 "
657SELECT cm.id,
658 cm.created_at,
659 cm.updated_at,
660 cm.deleted_at,
661 cm.name,
662 cm.course_id,
663 cm.order_number,
664 cm.copied_from,
665 cm.uh_course_code,
666 cm.automatic_completion,
667 cm.automatic_completion_number_of_exercises_attempted_treshold,
668 cm.automatic_completion_number_of_points_treshold,
669 cm.automatic_completion_requires_exam,
670 cm.completion_registration_link_override,
671 cm.ects_credits,
672 cm.enable_registering_completion_to_uh_open_university,
673 cm.certification_enabled,
674 enable_credit_registration_via_suotar
675FROM course_modules as cm
676WHERE EXISTS (
677 SELECT 1
678 FROM chapters as ch
679 WHERE ch.course_module_id = cm.id
680 AND ((ch.opens_at < now()) OR ch.opens_at IS NULL)
681 AND ch.deleted_at IS NULL
682)
683 AND cm.course_id = $1
684 AND cm.deleted_at IS NULL
685",
686 course_id
687 )
688 .map(|x| x.into())
689 .fetch_all(conn)
690 .await?;
691 Ok(res)
692}
693
694pub async fn get_by_exercise_id(
697 conn: &mut PgConnection,
698 exercise_id: Uuid,
699) -> ModelResult<CourseModule> {
700 let res = sqlx::query_as!(
701 CourseModulesSchema,
702 r#"
703SELECT course_modules.id AS "id!",
704 course_modules.created_at AS "created_at!",
705 course_modules.updated_at AS "updated_at!",
706 course_modules.deleted_at,
707 course_modules.name,
708 course_modules.course_id AS "course_id!",
709 course_modules.order_number AS "order_number!",
710 course_modules.copied_from,
711 course_modules.uh_course_code,
712 course_modules.automatic_completion AS "automatic_completion!",
713 course_modules.automatic_completion_number_of_exercises_attempted_treshold,
714 course_modules.automatic_completion_number_of_points_treshold,
715 course_modules.automatic_completion_requires_exam AS "automatic_completion_requires_exam!",
716 course_modules.completion_registration_link_override,
717 course_modules.ects_credits,
718 course_modules.enable_registering_completion_to_uh_open_university AS "enable_registering_completion_to_uh_open_university!",
719 course_modules.certification_enabled AS "certification_enabled!",
720 course_modules.enable_credit_registration_via_suotar AS "enable_credit_registration_via_suotar!"
721FROM exercises
722 LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
723 LEFT JOIN course_modules ON (chapters.course_module_id = course_modules.id)
724WHERE exercises.id = $1
725 AND chapters.deleted_at IS NULL
726 AND course_modules.deleted_at IS NULL
727 "#,
728 exercise_id,
729 )
730 .fetch_one(conn)
731 .await?;
732 Ok(res.into())
733}
734
735pub async fn get_course_module_id_by_chapter(
736 conn: &mut PgConnection,
737 chapter_id: Uuid,
738) -> ModelResult<Uuid> {
739 let res: Uuid = sqlx::query!(
740 r#"
741SELECT c.course_module_id
742from chapters c
743where c.id = $1
744 AND deleted_at IS NULL
745 "#,
746 chapter_id
747 )
748 .map(|record| record.course_module_id)
749 .fetch_one(conn)
750 .await?;
751 Ok(res)
752}
753
754pub async fn get_chapter_and_exercise_counts(
755 conn: &mut PgConnection,
756 course_module_id: Uuid,
757) -> ModelResult<ModuleSizeCounts> {
758 let res = sqlx::query_as!(
759 ModuleSizeCounts,
760 r#"
761SELECT COUNT(DISTINCT c.id) AS "chapters!",
762 COUNT(e.id) AS "exercises!"
763FROM chapters c
764 LEFT JOIN exercises e ON e.chapter_id = c.id
765 AND e.deleted_at IS NULL
766WHERE c.course_module_id = $1
767 AND c.deleted_at IS NULL
768 "#,
769 course_module_id
770 )
771 .fetch_one(conn)
772 .await?;
773 Ok(res)
774}
775
776pub async fn get_default_by_course_id(
777 conn: &mut PgConnection,
778 course_id: Uuid,
779) -> ModelResult<CourseModule> {
780 let res = sqlx::query_as!(
781 CourseModulesSchema,
782 "
783SELECT id,
784 created_at,
785 updated_at,
786 deleted_at,
787 name,
788 course_id,
789 order_number,
790 copied_from,
791 uh_course_code,
792 automatic_completion,
793 automatic_completion_number_of_exercises_attempted_treshold,
794 automatic_completion_number_of_points_treshold,
795 automatic_completion_requires_exam,
796 completion_registration_link_override,
797 ects_credits,
798 enable_registering_completion_to_uh_open_university,
799 certification_enabled,
800 enable_credit_registration_via_suotar
801FROM course_modules
802WHERE course_id = $1
803 AND name IS NULL
804 AND deleted_at IS NULL
805 ",
806 course_id,
807 )
808 .fetch_one(conn)
809 .await?;
810 Ok(res.into())
811}
812
813pub async fn get_ids_by_course_slug_or_uh_course_code(
818 conn: &mut PgConnection,
819 course_slug_or_code: &str,
820) -> ModelResult<Vec<Uuid>> {
821 let res = sqlx::query!(
822 r#"
823SELECT course_modules.id AS "id!"
824FROM course_modules
825 LEFT JOIN courses ON (course_modules.course_id = courses.id)
826WHERE (
827 course_modules.uh_course_code = $1
828 OR courses.slug = $1
829 )
830 AND course_modules.deleted_at IS NULL
831 "#,
832 course_slug_or_code,
833 )
834 .map(|record| record.id)
835 .fetch_all(conn)
836 .await?;
837 Ok(res)
838}
839
840pub async fn get_by_course_id_as_map(
842 conn: &mut PgConnection,
843 course_id: Uuid,
844) -> ModelResult<HashMap<Uuid, CourseModule>> {
845 let res = get_by_course_id(conn, course_id)
846 .await?
847 .into_iter()
848 .map(|course_module| (course_module.id, course_module))
849 .collect();
850 Ok(res)
851}
852
853pub async fn get_all_uh_course_codes_for_open_university(
854 conn: &mut PgConnection,
855) -> ModelResult<Vec<String>> {
856 let res = sqlx::query!(
857 "
858SELECT DISTINCT uh_course_code
859FROM course_modules
860WHERE uh_course_code IS NOT NULL
861 AND enable_registering_completion_to_uh_open_university = true
862 AND deleted_at IS NULL
863"
864 )
865 .fetch_all(conn)
866 .await?
867 .into_iter()
868 .filter_map(|x| x.uh_course_code)
869 .collect();
870 Ok(res)
871}
872
873pub async fn get_credit_registration_enabled_ids_for_course(
876 conn: &mut PgConnection,
877 course_id: Uuid,
878) -> ModelResult<Vec<Uuid>> {
879 let res = sqlx::query_scalar!(
880 "
881SELECT id
882FROM course_modules
883WHERE course_id = $1
884 AND enable_credit_registration_via_suotar
885 AND deleted_at IS NULL
886ORDER BY order_number
887 ",
888 course_id
889 )
890 .fetch_all(conn)
891 .await?;
892 Ok(res)
893}
894
895pub async fn get_ids_registering_eligible_new_completions_via_suotar(
898 conn: &mut PgConnection,
899 course_ids: &[Uuid],
900) -> ModelResult<Vec<Uuid>> {
901 let res = sqlx::query_scalar!(
902 "
903SELECT id
904FROM course_modules
905WHERE course_id = ANY($1)
906 AND enable_credit_registration_via_suotar
907 AND register_eligible_new_completions_via_suotar
908 AND deleted_at IS NULL
909 ",
910 course_ids
911 )
912 .fetch_all(conn)
913 .await?;
914 Ok(res)
915}
916
917pub async fn any_credit_registration_enabled(conn: &mut PgConnection) -> ModelResult<bool> {
921 let res = sqlx::query_scalar!(
922 r#"
923SELECT EXISTS(
924 SELECT 1
925 FROM course_modules
926 WHERE enable_credit_registration_via_suotar
927 AND deleted_at IS NULL
928) AS "exists!"
929 "#
930 )
931 .fetch_one(conn)
932 .await?;
933 Ok(res)
934}
935
936pub async fn get_credit_registration_course_ids_for_enrolled_user(
939 conn: &mut PgConnection,
940 user_id: Uuid,
941) -> ModelResult<Vec<Uuid>> {
942 let res = sqlx::query_scalar!(
943 "
944SELECT DISTINCT cm.course_id
945FROM course_modules cm
946 JOIN course_instance_enrollments cie ON cie.course_id = cm.course_id
947 JOIN courses c ON c.id = cm.course_id
948WHERE cie.user_id = $1
949 AND cie.deleted_at IS NULL
950 AND cm.enable_credit_registration_via_suotar
951 AND cm.deleted_at IS NULL
952 AND c.deleted_at IS NULL
953 ",
954 user_id
955 )
956 .fetch_all(conn)
957 .await?;
958 Ok(res)
959}
960
961async fn credit_registration_configs(
965 conn: &mut PgConnection,
966 course_module_id: Option<Uuid>,
967 course_id: Option<Uuid>,
968 enabled_only: bool,
969) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
970 let res = sqlx::query_as!(
971 CourseModuleCreditRegistrationConfig,
972 r#"
973SELECT cm.id AS course_module_id,
974 cm.course_id,
975 cm.enable_credit_registration_via_suotar,
976 cm.uh_course_code,
977 cm.ects_credits,
978 c.paused_at AS "credit_registration_paused_at?",
979 c.paused_by_user_id AS "credit_registration_paused_by_user_id?",
980 c.pause_reason AS "credit_registration_pause_reason?",
981 c.config_checked_at AS "credit_registration_config_checked_at?",
982 c.course_code_allowed AS "credit_registration_course_code_allowed?",
983 c.config_check_message AS "credit_registration_config_check_message?",
984 COALESCE(TRIM(cm.completion_registration_link_override) <> '', FALSE) AS "credit_registration_has_enrolment_link!"
985FROM course_modules cm
986 LEFT JOIN course_module_suotar_configurations c ON c.course_module_id = cm.id
987 AND c.deleted_at IS NULL
988WHERE ($1::uuid IS NULL OR cm.id = $1)
989 AND ($2::uuid IS NULL OR cm.course_id = $2)
990 AND (NOT $3::bool OR cm.enable_credit_registration_via_suotar)
991 AND cm.deleted_at IS NULL
992ORDER BY cm.course_id,
993 cm.order_number
994 "#,
995 course_module_id,
996 course_id,
997 enabled_only,
998 )
999 .fetch_all(conn)
1000 .await?;
1001 Ok(res)
1002}
1003
1004pub async fn get_credit_registration_config(
1005 conn: &mut PgConnection,
1006 course_module_id: Uuid,
1007) -> ModelResult<CourseModuleCreditRegistrationConfig> {
1008 credit_registration_configs(conn, Some(course_module_id), None, false)
1009 .await?
1010 .into_iter()
1011 .next()
1012 .ok_or_else(missing_model_error(
1013 ModelErrorType::RecordNotFound,
1014 "Course module not found".to_string(),
1015 ))
1016}
1017
1018pub async fn get_credit_registration_configs_by_course_id(
1021 conn: &mut PgConnection,
1022 course_id: Uuid,
1023) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
1024 credit_registration_configs(conn, None, Some(course_id), false).await
1025}
1026
1027pub async fn get_all_suotar_enabled(
1029 conn: &mut PgConnection,
1030) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
1031 credit_registration_configs(conn, None, None, true).await
1032}
1033
1034pub async fn update_automatic_completion_status(
1035 conn: &mut PgConnection,
1036 id: Uuid,
1037 automatic_completion_policy: &CompletionPolicy,
1038) -> ModelResult<CourseModule> {
1039 let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
1040 automatic_completion_policy.to_database_fields();
1041 let res = sqlx::query_as!(
1042 CourseModulesSchema,
1043 "
1044UPDATE course_modules
1045SET automatic_completion = $1,
1046 automatic_completion_number_of_exercises_attempted_treshold = $2,
1047 automatic_completion_number_of_points_treshold = $3,
1048 automatic_completion_requires_exam = $4
1049WHERE id = $5
1050 AND deleted_at IS NULL
1051RETURNING id,
1052 created_at,
1053 updated_at,
1054 deleted_at,
1055 name,
1056 course_id,
1057 order_number,
1058 copied_from,
1059 uh_course_code,
1060 automatic_completion,
1061 automatic_completion_number_of_exercises_attempted_treshold,
1062 automatic_completion_number_of_points_treshold,
1063 automatic_completion_requires_exam,
1064 completion_registration_link_override,
1065 ects_credits,
1066 enable_registering_completion_to_uh_open_university,
1067 certification_enabled,
1068 enable_credit_registration_via_suotar
1069 ",
1070 automatic_completion,
1071 exercises_treshold,
1072 points_treshold,
1073 requires_exam,
1074 id,
1075 )
1076 .fetch_one(conn)
1077 .await?;
1078 Ok(res.into())
1079}
1080
1081pub async fn update_uh_course_code(
1082 conn: &mut PgConnection,
1083 id: Uuid,
1084 uh_course_code: Option<String>,
1085) -> ModelResult<CourseModule> {
1086 let res = sqlx::query_as!(
1087 CourseModulesSchema,
1088 "
1089UPDATE course_modules
1090SET uh_course_code = $1
1091WHERE id = $2
1092 AND deleted_at IS NULL
1093RETURNING id,
1094 created_at,
1095 updated_at,
1096 deleted_at,
1097 name,
1098 course_id,
1099 order_number,
1100 copied_from,
1101 uh_course_code,
1102 automatic_completion,
1103 automatic_completion_number_of_exercises_attempted_treshold,
1104 automatic_completion_number_of_points_treshold,
1105 automatic_completion_requires_exam,
1106 completion_registration_link_override,
1107 ects_credits,
1108 enable_registering_completion_to_uh_open_university,
1109 certification_enabled,
1110 enable_credit_registration_via_suotar
1111 ",
1112 uh_course_code,
1113 id,
1114 )
1115 .fetch_one(conn)
1116 .await?;
1117 Ok(res.into())
1118}
1119
1120pub async fn update_enable_registering_completion_to_uh_open_university(
1121 conn: &mut PgConnection,
1122 id: Uuid,
1123 enable_registering_completion_to_uh_open_university: bool,
1124) -> ModelResult<CourseModule> {
1125 let res = sqlx::query_as!(
1126 CourseModulesSchema,
1127 "
1128UPDATE course_modules
1129SET enable_registering_completion_to_uh_open_university = $1
1130WHERE id = $2
1131 AND deleted_at IS NULL
1132RETURNING id,
1133 created_at,
1134 updated_at,
1135 deleted_at,
1136 name,
1137 course_id,
1138 order_number,
1139 copied_from,
1140 uh_course_code,
1141 automatic_completion,
1142 automatic_completion_number_of_exercises_attempted_treshold,
1143 automatic_completion_number_of_points_treshold,
1144 automatic_completion_requires_exam,
1145 completion_registration_link_override,
1146 ects_credits,
1147 enable_registering_completion_to_uh_open_university,
1148 certification_enabled,
1149 enable_credit_registration_via_suotar
1150 ",
1151 enable_registering_completion_to_uh_open_university,
1152 id,
1153 )
1154 .fetch_one(conn)
1155 .await?;
1156 Ok(res.into())
1157}
1158
1159pub async fn update_with_order_number(
1160 conn: &mut PgConnection,
1161 id: Uuid,
1162 name: Option<&str>,
1163 order_number: i32,
1164) -> ModelResult<()> {
1165 sqlx::query!(
1166 "
1167UPDATE course_modules
1168SET name = COALESCE($1, name),
1169 order_number = $2
1170WHERE id = $3
1171",
1172 name,
1173 order_number,
1174 id,
1175 )
1176 .execute(conn)
1177 .await?;
1178 Ok(())
1179}
1180
1181pub async fn update(
1182 conn: &mut PgConnection,
1183 id: Uuid,
1184 updated_course_module: &NewCourseModule,
1185) -> ModelResult<()> {
1186 let NewCourseModule {
1188 completion_policy: _,
1189 course_id: _,
1190 ects_credits,
1191 order_number,
1192 name,
1193 uh_course_code,
1194 completion_registration_link_override,
1195 enable_registering_completion_to_uh_open_university,
1196 enable_credit_registration_via_suotar,
1197 } = updated_course_module;
1198 let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
1199 updated_course_module.completion_policy.to_database_fields();
1200 sqlx::query!(
1201 "
1202UPDATE course_modules
1203SET name = COALESCE($2, name),
1204 order_number = $3,
1205 uh_course_code = $4,
1206 ects_credits = $5,
1207 automatic_completion = $6,
1208 automatic_completion_number_of_exercises_attempted_treshold = $7,
1209 automatic_completion_number_of_points_treshold = $8,
1210 automatic_completion_requires_exam = $9,
1211 completion_registration_link_override = $10,
1212 enable_registering_completion_to_uh_open_university = $11,
1213 enable_credit_registration_via_suotar = $12,
1214 register_eligible_new_completions_via_suotar = register_eligible_new_completions_via_suotar
1215 AND $12
1216WHERE id = $1
1217 ",
1218 id,
1219 name.as_ref(),
1220 order_number,
1221 uh_course_code.as_ref(),
1222 ects_credits.as_ref(),
1223 automatic_completion,
1224 exercises_treshold,
1225 points_treshold,
1226 requires_exam,
1227 completion_registration_link_override.as_ref(),
1228 enable_registering_completion_to_uh_open_university,
1229 enable_credit_registration_via_suotar
1230 )
1231 .execute(conn)
1232 .await?;
1233 Ok(())
1234}
1235
1236pub async fn set_register_eligible_new_completions_via_suotar(
1239 conn: &mut PgConnection,
1240 id: Uuid,
1241 value: bool,
1242) -> ModelResult<()> {
1243 sqlx::query!(
1244 "
1245UPDATE course_modules
1246SET register_eligible_new_completions_via_suotar = $2
1247WHERE id = $1
1248 ",
1249 id,
1250 value,
1251 )
1252 .execute(conn)
1253 .await?;
1254 Ok(())
1255}
1256
1257pub async fn would_change_credit_registration_via_suotar(
1265 conn: &mut PgConnection,
1266 course_id: Uuid,
1267 updates: &ModuleUpdates,
1268) -> ModelResult<bool> {
1269 if updates
1270 .new_modules
1271 .iter()
1272 .any(|module| module.enable_credit_registration_via_suotar)
1273 {
1274 return Ok(true);
1275 }
1276 let stored: HashMap<Uuid, bool> = get_by_course_id(conn, course_id)
1277 .await?
1278 .into_iter()
1279 .map(|module| (module.id, module.enable_credit_registration_via_suotar))
1280 .collect();
1281 Ok(updates.modified_modules.iter().any(|module| {
1282 stored.get(&module.id).copied().unwrap_or(false)
1283 != module.enable_credit_registration_via_suotar
1284 }))
1285}
1286
1287pub fn validate_completion_registration_link(link: Option<&str>) -> ModelResult<()> {
1290 let Some(link) = link.map(str::trim).filter(|link| !link.is_empty()) else {
1291 return Ok(());
1292 };
1293 match Url::parse(link) {
1294 Ok(url) if url.scheme() == "https" && url.host().is_some() => Ok(()),
1295 _ => Err(model_err!(
1296 InvalidRequest,
1297 "The completion registration link must be an absolute https:// URL.".to_string()
1298 )),
1299 }
1300}
1301
1302async fn ensure_belong_to_course(
1304 conn: &mut PgConnection,
1305 course_id: Uuid,
1306 module_ids: &[Uuid],
1307 chapter_ids: &[Uuid],
1308) -> ModelResult<()> {
1309 let res = sqlx::query!(
1310 r#"
1311SELECT NOT EXISTS (
1312 SELECT 1
1313 FROM UNNEST($2::uuid []) AS ids(id)
1314 WHERE NOT EXISTS (
1315 SELECT 1
1316 FROM course_modules cm
1317 WHERE cm.id = ids.id
1318 AND cm.course_id = $1
1319 AND cm.deleted_at IS NULL
1320 )
1321 )
1322 AND NOT EXISTS (
1323 SELECT 1
1324 FROM UNNEST($3::uuid []) AS ids(id)
1325 WHERE NOT EXISTS (
1326 SELECT 1
1327 FROM chapters c
1328 WHERE c.id = ids.id
1329 AND c.course_id = $1
1330 AND c.deleted_at IS NULL
1331 )
1332 ) AS "all_belong!"
1333"#,
1334 course_id,
1335 module_ids,
1336 chapter_ids,
1337 )
1338 .fetch_one(conn)
1339 .await?;
1340 if !res.all_belong {
1341 return Err(model_err!(
1342 InvalidRequest,
1343 "Every module and chapter in the update must belong to the course.".to_string()
1344 ));
1345 }
1346 Ok(())
1347}
1348
1349pub async fn update_modules(
1354 conn: &mut PgConnection,
1355 course_id: Uuid,
1356 updates: ModuleUpdates,
1357) -> ModelResult<()> {
1358 for link in updates
1359 .new_modules
1360 .iter()
1361 .map(|m| &m.completion_registration_link_override)
1362 .chain(
1363 updates
1364 .modified_modules
1365 .iter()
1366 .map(|m| &m.completion_registration_link_override),
1367 )
1368 {
1369 validate_completion_registration_link(link.as_deref())?;
1370 }
1371
1372 let mut tx = conn.begin().await?;
1373
1374 let module_ids: Vec<Uuid> = updates
1375 .modified_modules
1376 .iter()
1377 .map(|m| m.id)
1378 .chain(updates.deleted_modules.iter().copied())
1379 .chain(updates.moved_chapters.iter().map(|(_, module)| *module))
1380 .collect();
1381 let chapter_ids: Vec<Uuid> = updates
1382 .new_modules
1383 .iter()
1384 .flat_map(|m| m.chapters.iter().copied())
1385 .chain(updates.moved_chapters.iter().map(|(chapter, _)| *chapter))
1386 .collect();
1387 ensure_belong_to_course(&mut tx, course_id, &module_ids, &chapter_ids).await?;
1388
1389 for module_id in updates
1391 .modified_modules
1392 .iter()
1393 .filter(|m| m.order_number != 0)
1395 .map(|m| m.id)
1396 .chain(updates.deleted_modules.iter().copied())
1397 {
1398 update_with_order_number(&mut tx, module_id, None, rand::random()).await?;
1399 }
1400 let mut modified_and_new_modules = updates.modified_modules;
1401 for new in updates.new_modules {
1402 let NewModule {
1404 name,
1405 order_number,
1406 chapters,
1407 uh_course_code,
1408 ects_credits,
1409 completion_policy,
1410 completion_registration_link_override,
1411 enable_registering_completion_to_uh_open_university,
1412 enable_credit_registration_via_suotar,
1413 } = new;
1414 let new_course_module = NewCourseModule::new(course_id, Some(name.clone()), rand::random())
1416 .set_completion_policy(completion_policy.clone())
1417 .set_completion_registration_link_override(completion_registration_link_override)
1418 .set_ects_credits(ects_credits)
1419 .set_uh_course_code(uh_course_code)
1420 .set_enable_registering_completion_to_uh_open_university(
1421 enable_registering_completion_to_uh_open_university,
1422 )
1423 .set_enable_credit_registration_via_suotar(enable_credit_registration_via_suotar);
1424 let module = insert(&mut tx, PKeyPolicy::Generate, &new_course_module).await?;
1425 for chapter in chapters {
1426 chapters::set_module(&mut tx, chapter, module.id).await?;
1427 }
1428 modified_and_new_modules.push(ModifiedModule {
1430 id: module.id,
1431 name: None,
1432 order_number,
1433 uh_course_code: module.uh_course_code,
1434 ects_credits,
1435 completion_policy,
1436 completion_registration_link_override: module.completion_registration_link_override,
1437 enable_registering_completion_to_uh_open_university: module
1438 .enable_registering_completion_to_uh_open_university,
1439 enable_credit_registration_via_suotar,
1440 })
1441 }
1442 for module in modified_and_new_modules {
1444 let ModifiedModule {
1446 id,
1447 name,
1448 order_number,
1449 uh_course_code,
1450 ects_credits,
1451 completion_policy,
1452 completion_registration_link_override,
1453 enable_registering_completion_to_uh_open_university,
1454 enable_credit_registration_via_suotar,
1455 } = module;
1456 update(
1457 &mut tx,
1458 id,
1459 &NewCourseModule::new(course_id, name.clone(), order_number)
1460 .set_completion_policy(completion_policy)
1461 .set_completion_registration_link_override(completion_registration_link_override)
1462 .set_ects_credits(ects_credits)
1463 .set_uh_course_code(uh_course_code)
1464 .set_enable_registering_completion_to_uh_open_university(
1465 enable_registering_completion_to_uh_open_university,
1466 )
1467 .set_enable_credit_registration_via_suotar(enable_credit_registration_via_suotar),
1468 )
1469 .await?;
1470 }
1471 for (chapter, module) in updates.moved_chapters {
1472 chapters::set_module(&mut tx, chapter, module).await?;
1473 }
1474 for deleted in updates.deleted_modules {
1475 delete(&mut tx, deleted).await?;
1476 }
1477
1478 tx.commit().await?;
1479 Ok(())
1480}
1481
1482pub async fn update_certification_enabled(
1483 conn: &mut PgConnection,
1484 id: Uuid,
1485 enabled: bool,
1486) -> ModelResult<()> {
1487 sqlx::query!(
1488 "
1489UPDATE course_modules
1490SET certification_enabled = $1
1491WHERE id = $2
1492",
1493 enabled,
1494 id
1495 )
1496 .execute(conn)
1497 .await?;
1498 Ok(())
1499}
1500
1501#[cfg(test)]
1502mod tests {
1503
1504 mod automatic_completion_requirements {
1505 use uuid::Uuid;
1506
1507 use super::super::AutomaticCompletionRequirements;
1508
1509 #[test]
1510 fn passes_exercise_tresholds() {
1511 let requirements1 = AutomaticCompletionRequirements {
1512 course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1513 number_of_exercises_attempted_treshold: Some(10),
1514 number_of_points_treshold: Some(50),
1515 requires_exam: false,
1516 };
1517 let requirements2 = AutomaticCompletionRequirements {
1518 course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1519 number_of_exercises_attempted_treshold: Some(50),
1520 number_of_points_treshold: Some(10),
1521 requires_exam: false,
1522 };
1523
1524 let requirements3 = AutomaticCompletionRequirements {
1525 course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1526 number_of_exercises_attempted_treshold: Some(0),
1527 number_of_points_treshold: Some(0),
1528 requires_exam: false,
1529 };
1530
1531 let requirements4 = AutomaticCompletionRequirements {
1532 course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1533 number_of_exercises_attempted_treshold: Some(10),
1534 number_of_points_treshold: None,
1535 requires_exam: false,
1536 };
1537
1538 let requirements5 = AutomaticCompletionRequirements {
1539 course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1540 number_of_exercises_attempted_treshold: None,
1541 number_of_points_treshold: Some(10),
1542 requires_exam: false,
1543 };
1544 assert!(requirements1.passes_exercise_tresholds(10, 50));
1545 assert!(requirements2.passes_exercise_tresholds(50, 10));
1546
1547 assert!(!requirements1.passes_exercise_tresholds(50, 10));
1548 assert!(!requirements2.passes_exercise_tresholds(10, 50));
1549
1550 assert!(!requirements1.passes_exercise_tresholds(100, 0));
1551 assert!(!requirements2.passes_exercise_tresholds(100, 0));
1552
1553 assert!(requirements3.passes_exercise_tresholds(1, 1));
1554 assert!(requirements3.passes_exercise_tresholds(0, 0));
1555
1556 assert!(requirements4.passes_exercise_tresholds(10, 1));
1557 assert!(!requirements4.passes_exercise_tresholds(1, 10));
1558
1559 assert!(requirements5.passes_exercise_tresholds(0, 10));
1560 assert!(!requirements5.passes_exercise_tresholds(10, 0));
1561 }
1562 }
1563}