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