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