1use std::collections::HashMap;
8
9use anyhow::Result;
10use chrono::{Duration, Utc};
11use headless_lms_base::config::{
12 ApplicationConfiguration, SuotarConfiguration, bool_env_false_by_default,
13};
14use headless_lms_models::library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource;
15use headless_lms_models::{
16 PKeyPolicy,
17 course_instance_enrollments::{self, NewCourseInstanceEnrollment},
18 course_module_completions::{self, NewCourseModuleCompletionSeed},
19 course_modules,
20 credit_registration_account_linking_emails::{self, NewAccountLinkingEmail},
21 credit_registration_admin_actions::{
22 self, COURSE_TEACHER_ROLE, CreditRegistrationAdminAction,
23 CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE, NewCreditRegistrationAdminAction,
24 },
25 credit_registration_enrolment_check_signals,
26 credit_registrations::{
27 self, CreditRegistrationErrorCode, CreditRegistrationState, NewCreditRegistration,
28 PayloadSnapshot, Transition,
29 },
30 open_university_registration_links,
31 roles::UserRole,
32 secret::DbSecret,
33 student_number_verification_tokens::{self, SeedStudentNumberVerificationToken},
34 study_registry_registrars::{self, get_or_create_default_registrar},
35 user_ai_usage_notice_acknowledgements, user_details,
36 user_passwords::{hash_password, upsert_user_password},
37 users,
38 verified_student_numbers::{self, NewVerifiedStudentNumber, StudentNumberVerificationMethod},
39};
40use headless_lms_utils::http::REQWEST_CLIENT;
41use secrecy::SecretString;
42use sqlx::{Connection, PgConnection};
43use tracing::info;
44use uuid::Uuid;
45
46use crate::mock_suotar::fixtures::*;
47use crate::programs::seed::builder::{
48 chapter::ChapterBuilder,
49 context::SeedContext,
50 course::{CourseBuilder, CourseInstanceConfig},
51 module::{
52 CompletionBuilder, CompletionRegisteredBuilder, CreditRegistrationSeed, ModuleBuilder,
53 },
54 page::PageBuilder,
55};
56use crate::programs::seed::seed_courses::CommonCourseData;
57use crate::programs::seed::seed_helpers::paragraph;
58
59pub const CERTIFICATE_DETOUR_COURSE_ID: Uuid =
62 Uuid::from_u128(0xc5ed17ea_0010_4a5e_9e6e_c0de00000010);
63pub const CERTIFICATE_DETOUR_COURSE_SLUG: &str = "credit-registration-certificate-detour";
64pub const CRS_DETOUR_101: &str = "CRS-DETOUR-101";
65
66pub const PULL_REGISTRAR_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0008_4a5e_9e6e_c0de00000008);
69pub const PULL_REGISTRAR_SECRET_KEY: &str = "credit-registration-system-tests-pull-registrar";
70
71pub const SUPERSEDED_ATTEMPT_1_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0901_4a5e_9e6e_c0de00000901);
73pub const SUPERSEDED_ATTEMPT_2_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0902_4a5e_9e6e_c0de00000902);
74
75pub const LINKING_TOKEN_VALID: &str = concat!(
80 "11111111-1111-1111-1111-111111111111",
81 "11111111-1111-1111-1111-111111111111",
82 "11111111-1111-1111-1111-111111111111",
83 "11111111-1111-1111-1111-111111111111",
84);
85pub const LINKING_TOKEN_EXPIRED: &str = concat!(
86 "22222222-2222-2222-2222-222222222222",
87 "22222222-2222-2222-2222-222222222222",
88 "22222222-2222-2222-2222-222222222222",
89 "22222222-2222-2222-2222-222222222222",
90);
91pub const LINKING_TOKEN_ALREADY_USED: &str = concat!(
92 "33333333-3333-3333-3333-333333333333",
93 "33333333-3333-3333-3333-333333333333",
94 "33333333-3333-3333-3333-333333333333",
95 "33333333-3333-3333-3333-333333333333",
96);
97pub const LINKING_TOKEN_CONFLICT: &str = concat!(
100 "44444444-4444-4444-4444-444444444444",
101 "44444444-4444-4444-4444-444444444444",
102 "44444444-4444-4444-4444-444444444444",
103 "44444444-4444-4444-4444-444444444444",
104);
105
106const PROFILE_EMPTY_EMAIL: &str = "student5@example.com";
108
109struct SeededStudent {
111 user_id: Uuid,
112 email: String,
113}
114
115struct LinkedStudents {
117 by_student_number: HashMap<&'static str, SeededStudent>,
118}
119
120impl LinkedStudents {
121 fn get(&self, fixture: &MockPersonFixture) -> Result<&SeededStudent> {
122 self.by_student_number
123 .get(fixture.student_number)
124 .ok_or_else(|| anyhow::anyhow!("{} was never seeded", fixture.student_number))
125 }
126}
127
128#[derive(Clone, Copy)]
130struct SeededCourse {
131 course_id: Uuid,
132 course_instance_id: Uuid,
133}
134
135pub async fn seed_credit_registration(
136 app_config: &ApplicationConfiguration,
137 common_course_data: CommonCourseData,
138) -> Result<Uuid> {
139 let CommonCourseData {
140 db_pool,
141 organization_id: org,
142 teacher_user_id,
143 base_url,
144 ..
145 } = common_course_data;
146
147 let mut conn = db_pool.acquire().await?;
148 let cx = SeedContext {
149 teacher: teacher_user_id,
150 org,
151 base_course_ns: SUOTAR_COURSE_ID,
152 };
153
154 info!("inserting credit registration students");
156 let students = seed_linked_students(&mut conn, &cx).await?;
157
158 info!("inserting credit registration courses");
159 let suotar = seed_general_course(
160 &mut conn,
161 app_config,
162 &cx,
163 GeneralCourse {
164 name: "Credit registration via Suotar",
165 slug: SUOTAR_COURSE_SLUG,
166 course_id: SUOTAR_COURSE_ID,
167 course_code: CRS_101,
168 has_chapter: true,
170 },
171 )
172 .await?;
173 let suotar_b = seed_general_course(
174 &mut conn,
175 app_config,
176 &cx,
177 GeneralCourse {
178 name: "Credit registration via Suotar B",
179 slug: SUOTAR_B_COURSE_SLUG,
180 course_id: SUOTAR_B_COURSE_ID,
181 course_code: CRS_B_101,
182 has_chapter: false,
183 },
184 )
185 .await?;
186 let lanes = HashMap::from([(CRS_101, suotar), (CRS_B_101, suotar_b)]);
187
188 seed_old_flow_course(&mut conn, app_config, org, teacher_user_id, &students).await?;
189 seed_certificate_detour_course(
190 &mut conn,
191 app_config,
192 org,
193 teacher_user_id,
194 students.get(&STUDENT_7)?,
195 students.get(&STUDENT_8)?,
196 )
197 .await?;
198 seed_import_outcomes_course(
199 &mut conn,
200 app_config,
201 org,
202 teacher_user_id,
203 students.get(&STUDENT_8)?,
204 )
205 .await?;
206 seed_grade_improvement_course(
207 &mut conn,
208 app_config,
209 org,
210 teacher_user_id,
211 students.get(&CREDIT_REGISTRATION_STUDENT_2)?,
212 )
213 .await?;
214 seed_admin_course(
215 &mut conn,
216 app_config,
217 org,
218 teacher_user_id,
219 students.get(&CREDIT_REGISTRATION_STUDENT_1)?,
220 )
221 .await?;
222 seed_states_course(&mut conn, app_config, org, teacher_user_id, &students).await?;
223 seed_retry_course(&mut conn, app_config, org, teacher_user_id, &students).await?;
224
225 info!("inserting credit registration completions");
226 for lane in &LANE_COMPLETIONS {
227 let course = lanes
228 .get(lane.course_code)
229 .ok_or_else(|| anyhow::anyhow!("no general course has code {}", lane.course_code))?;
230 let student = students.get(lane.student)?;
231 enroll(
232 &mut conn,
233 student.user_id,
234 course.course_id,
235 course.course_instance_id,
236 )
237 .await?;
238 let module_id = default_module_id(&mut conn, course.course_id).await?;
239 let completion_id =
240 seed_eligible_completion(&mut conn, student, module_id, course.course_id, None).await?;
241 if lane.student.student_number == STUDENT_6.student_number {
244 credit_registration_enrolment_check_signals::record_check_request(
245 &mut conn,
246 completion_id,
247 EnrolmentCheckSource::StudentRequest,
248 )
249 .await?;
250 }
251 }
252
253 let link_claimer = insert_student(
254 &mut conn,
255 cx.v5(b"user:link-claimer"),
256 LINK_CLAIMER_EMAIL,
257 "Zzyzx",
258 "Claimer",
259 )
260 .await?;
261 let profile_empty_user_id = users::get_by_email(&mut conn, PROFILE_EMPTY_EMAIL)
262 .await?
263 .id;
264 let replaced_attempts_holder = students.get(&STUDENT_6)?;
265 for user_id in [
266 link_claimer.user_id,
267 profile_empty_user_id,
268 replaced_attempts_holder.user_id,
269 ] {
270 enroll(
271 &mut conn,
272 user_id,
273 suotar.course_id,
274 suotar.course_instance_id,
275 )
276 .await?;
277 }
278
279 info!("inserting credit registration linking tokens");
280 seed_linking_tokens(
281 &mut conn,
282 &cx,
283 suotar.course_id,
284 replaced_attempts_holder.user_id,
285 )
286 .await?;
287
288 info!("inserting credit registration ledger history");
289 seed_superseded_attempt_pair(&mut conn, replaced_attempts_holder, suotar).await?;
290
291 study_registry_registrars::insert(
292 &mut conn,
293 PKeyPolicy::Fixed(PULL_REGISTRAR_ID),
294 "Credit registration system tests (pull)",
295 PULL_REGISTRAR_SECRET_KEY,
296 )
297 .await?;
298
299 info!("inserting credit registration admin actions");
300 seed_admin_actions(&mut conn, &cx, suotar.course_id, teacher_user_id).await?;
301
302 push_mock_suotar_world(&base_url).await?;
303
304 Ok(SUOTAR_COURSE_ID)
305}
306
307async fn seed_linked_students(conn: &mut PgConnection, cx: &SeedContext) -> Result<LinkedStudents> {
310 let mut by_student_number = HashMap::new();
311 for fixture in [&STUDENT_6, &STUDENT_7, &STUDENT_8] {
312 let email = account_email(fixture)?;
313 let user_id = users::get_by_email(conn, email).await?.id;
314 link_student_number(
315 conn,
316 cx,
317 fixture,
318 user_id,
319 StudentNumberVerificationMethod::EmailedLink,
320 )
321 .await?;
322 by_student_number.insert(
323 fixture.student_number,
324 SeededStudent {
325 user_id,
326 email: email.to_string(),
327 },
328 );
329 }
330 for fixture in [
331 &CREDIT_REGISTRATION_STUDENT_1,
332 &CREDIT_REGISTRATION_STUDENT_2,
333 &CREDIT_REGISTRATION_STUDENT_3,
334 &CREDIT_REGISTRATION_STUDENT_4,
335 &CREDIT_REGISTRATION_STUDENT_5,
336 &CREDIT_REGISTRATION_STUDENT_6,
337 ] {
338 let email = account_email(fixture)?;
339 let student = insert_student(
340 conn,
341 cx.v5(email.as_bytes()),
342 email,
343 fixture.first_names,
344 fixture.last_name,
345 )
346 .await?;
347 let verified_via = if fixture.student_number == CREDIT_REGISTRATION_STUDENT_2.student_number
348 {
349 StudentNumberVerificationMethod::AdminManual
350 } else {
351 StudentNumberVerificationMethod::EmailedLink
352 };
353 link_student_number(conn, cx, fixture, student.user_id, verified_via).await?;
354 by_student_number.insert(fixture.student_number, student);
355 }
356 Ok(LinkedStudents { by_student_number })
357}
358
359fn account_email(fixture: &MockPersonFixture) -> Result<&'static str> {
360 fixture
361 .account_email
362 .ok_or_else(|| anyhow::anyhow!("{} has no account", fixture.student_number))
363}
364
365struct GeneralCourse {
366 name: &'static str,
367 slug: &'static str,
368 course_id: Uuid,
369 course_code: &'static str,
370 has_chapter: bool,
371}
372
373async fn seed_general_course(
375 conn: &mut PgConnection,
376 app_config: &ApplicationConfiguration,
377 cx: &SeedContext,
378 general: GeneralCourse,
379) -> Result<SeededCourse> {
380 let cx = SeedContext {
381 teacher: cx.teacher,
382 org: cx.org,
383 base_course_ns: general.course_id,
384 };
385 let mut module = ModuleBuilder::new()
386 .order(0)
387 .ects(5.0)
388 .uh_course_code(general.course_code.to_string())
389 .credit_registration(credit_registration_config(general.course_code));
390 if general.has_chapter {
391 module = module.chapter(
392 ChapterBuilder::new(1, "Registering credits")
393 .opens(Utc::now())
394 .fixed_ids(cx.v5(b"chapter:1"), cx.v5(b"chapter:1:front-page"))
395 .page(
396 PageBuilder::new("/chapter-1/page-1", "How registration works").block(
397 paragraph(
398 "Completing this module registers credits into Sisu.",
399 cx.v5(b"page:1:1:block"),
400 ),
401 ),
402 ),
403 );
404 }
405 let (course, instance, _) = CourseBuilder::new(general.name, general.slug)
406 .desc("Fixture course for the credit registration system tests.")
407 .course_id(general.course_id)
408 .role(cx.teacher, UserRole::Teacher)
409 .instance(instance_config(cx.v5(b"instance:suotar")))
410 .module(module)
411 .seed(conn, app_config, &cx)
412 .await?;
413 Ok(SeededCourse {
414 course_id: course.id,
415 course_instance_id: instance.id,
416 })
417}
418
419async fn push_mock_suotar_world(base_url: &str) -> Result<()> {
422 if !(bool_env_false_by_default("TEST_MODE")
423 && bool_env_false_by_default("USE_MOCK_SUOTAR_ENDPOINT"))
424 {
425 info!("mock Suotar is not enabled; leaving its world alone");
426 return Ok(());
427 }
428 let url = SuotarConfiguration::mock_conf(base_url)?
429 .api_base_url
430 .join("control/command")?;
431 let mut payload = serde_json::to_value(mock_suotar_world())?;
432 if let Some(object) = payload.as_object_mut() {
433 object.insert("command".to_string(), serde_json::json!("pushWorld"));
434 }
435
436 info!("pushing the mock Suotar world");
437 let response = REQWEST_CLIENT
438 .post(url.clone())
439 .json(&payload)
440 .send()
441 .await?;
442 let status = response.status();
443 if !status.is_success() {
444 let body = response.text().await.unwrap_or_default();
445 anyhow::bail!("pushing the mock Suotar world to {url} failed with {status}: {body}");
447 }
448 Ok(())
449}
450
451fn credit_registration_config(course_code: &str) -> CreditRegistrationSeed {
454 CreditRegistrationSeed {
455 enrolment_link: Some(format!(
456 "https://www.avoin.helsinki.fi/palvelut/esittely.aspx?s=seed-{course_code}"
457 )),
458 paused_reason: None,
459 register_eligible_new_completions: true,
460 }
461}
462
463fn instance_config(instance_id: Uuid) -> CourseInstanceConfig {
464 CourseInstanceConfig {
465 name: None,
466 description: None,
467 support_email: None,
468 teacher_in_charge_name: "admin".to_string(),
469 teacher_in_charge_email: "admin@example.com".to_string(),
470 opening_time: None,
471 closing_time: None,
472 instance_id: Some(instance_id),
473 }
474}
475
476async fn seed_old_flow_course(
483 conn: &mut PgConnection,
484 app_config: &ApplicationConfiguration,
485 org: Uuid,
486 teacher_user_id: Uuid,
487 students: &LinkedStudents,
488) -> Result<()> {
489 let cx = SeedContext {
490 teacher: teacher_user_id,
491 org,
492 base_course_ns: OLD_FLOW_COURSE_ID,
493 };
494 let registrar_id = get_or_create_default_registrar(conn).await?;
495 let still_legacy = students.get(&STUDENT_7)?;
496 let predates_opt_in = students.get(&STUDENT_8)?;
497
498 let (course, instance, _) =
499 CourseBuilder::new("Credit registration old flow", OLD_FLOW_COURSE_SLUG)
500 .desc("Fixture course left on the legacy open university registration flow.")
501 .course_id(OLD_FLOW_COURSE_ID)
502 .instance(instance_config(cx.v5(b"instance:old-flow")))
503 .module(
504 ModuleBuilder::new()
505 .order(0)
506 .ects(5.0)
507 .uh_course_code(CRS_OLD_101.to_string())
508 .register_to_open_university(true)
509 .completion(
510 CompletionBuilder::new(still_legacy.user_id)
511 .email(still_legacy.email.clone())
512 .grade(3)
513 .passed(true)
514 .prerequisite_modules_completed(true),
515 ),
516 )
517 .module(
518 ModuleBuilder::new()
519 .order(1)
520 .name("Opted into Suotar")
521 .ects(5.0)
522 .uh_course_code(CRS_OLD_102.to_string())
523 .credit_registration(CreditRegistrationSeed {
524 register_eligible_new_completions: false,
525 ..credit_registration_config(CRS_OLD_102)
526 })
527 .default_registrar(registrar_id)
528 .completion(
529 CompletionBuilder::new(predates_opt_in.user_id)
530 .email(predates_opt_in.email.clone())
531 .grade(3)
532 .passed(true)
533 .prerequisite_modules_completed(true)
534 .registered(
535 CompletionRegisteredBuilder::new()
536 .real_student_number(STUDENT_8.student_number),
537 ),
538 ),
539 )
540 .seed(conn, app_config, &cx)
541 .await?;
542
543 for student in [still_legacy, predates_opt_in] {
544 enroll(conn, student.user_id, course.id, instance.id).await?;
545 }
546 Ok(())
547}
548
549async fn seed_certificate_detour_course(
558 conn: &mut PgConnection,
559 app_config: &ApplicationConfiguration,
560 org: Uuid,
561 teacher_user_id: Uuid,
562 student: &SeededStudent,
563 failed_save_student: &SeededStudent,
564) -> Result<()> {
565 let cx = SeedContext {
566 teacher: teacher_user_id,
567 org,
568 base_course_ns: CERTIFICATE_DETOUR_COURSE_ID,
569 };
570
571 let (course, instance, module) = CourseBuilder::new(
572 "Credit registration certificate detour",
573 CERTIFICATE_DETOUR_COURSE_SLUG,
574 )
575 .desc("Fixture course whose open university module also offers a certificate.")
576 .course_id(CERTIFICATE_DETOUR_COURSE_ID)
577 .instance(instance_config(cx.v5(b"instance:certificate-detour")))
578 .module(
579 ModuleBuilder::new()
580 .order(0)
581 .ects(5.0)
582 .uh_course_code(CRS_DETOUR_101.to_string())
583 .register_to_open_university(true)
584 .completion(
585 CompletionBuilder::new(student.user_id)
586 .email(student.email.clone())
587 .grade(3)
588 .passed(true)
589 .prerequisite_modules_completed(true),
590 )
591 .completion(
592 CompletionBuilder::new(failed_save_student.user_id)
593 .email(failed_save_student.email.clone())
594 .grade(3)
595 .passed(true)
596 .prerequisite_modules_completed(true),
597 ),
598 )
599 .certificate_config("svgs/certificate-background.svg", None)
600 .seed(conn, app_config, &cx)
601 .await?;
602
603 course_modules::update_certification_enabled(conn, module.id, true).await?;
604 open_university_registration_links::upsert(conn, CRS_DETOUR_101, "https://www.example.com")
605 .await?;
606 for enrolled_student in [student, failed_save_student] {
607 enroll(conn, enrolled_student.user_id, course.id, instance.id).await?;
608 }
609 Ok(())
610}
611
612async fn seed_admin_course(
618 conn: &mut PgConnection,
619 app_config: &ApplicationConfiguration,
620 org: Uuid,
621 teacher_user_id: Uuid,
622 linked: &SeededStudent,
623) -> Result<()> {
624 let cx = SeedContext {
625 teacher: teacher_user_id,
626 org,
627 base_course_ns: ADMIN_COURSE_ID,
628 };
629 let (course, instance, module) =
630 CourseBuilder::new("Credit registration admin", ADMIN_COURSE_SLUG)
631 .desc("Fixture course for the admin dashboard's account linking views.")
632 .course_id(ADMIN_COURSE_ID)
633 .role(teacher_user_id, UserRole::Teacher)
634 .instance(instance_config(cx.v5(b"instance:admin")))
635 .module(
636 ModuleBuilder::new()
637 .order(0)
638 .ects(5.0)
639 .uh_course_code(CRS_ADMIN_101.to_string())
640 .credit_registration(credit_registration_config(CRS_ADMIN_101)),
641 )
642 .seed(conn, app_config, &cx)
643 .await?;
644
645 enroll(conn, linked.user_id, course.id, instance.id).await?;
646 seed_eligible_completion(conn, linked, module.id, course.id, None).await?;
647
648 for fixture in [&ADMIN_STALE, &TEACHER_RESEND_CAPPED] {
649 for suffix in MAILED_ADDRESS_SUFFIXES {
650 let address = format!("{suffix}{}", fixture.sisu_email);
651 let claimed = credit_registration_account_linking_emails::claim_send_slot(
652 conn,
653 &NewAccountLinkingEmail {
654 student_number: DbSecret::new(fixture.student_number),
655 sisu_person_id: DbSecret::new(fixture.sisu_person_id()),
656 course_id: course.id,
657 emailed_to: DbSecret::new(address.clone()),
658 student_number_verification_token_id: None,
659 email_delivery_id: None,
660 },
661 )
662 .await?;
663 anyhow::ensure!(
664 claimed.is_some(),
665 "the dedup key refused a seeded linking mail to {address}"
666 );
667 }
668 }
669 Ok(())
670}
671
672async fn seed_states_course(
679 conn: &mut PgConnection,
680 app_config: &ApplicationConfiguration,
681 org: Uuid,
682 teacher_user_id: Uuid,
683 students: &LinkedStudents,
684) -> Result<()> {
685 let cx = SeedContext {
686 teacher: teacher_user_id,
687 org,
688 base_course_ns: STATES_COURSE_ID,
689 };
690 let mut course = CourseBuilder::new("Credit registration states", STATES_COURSE_SLUG)
691 .desc("Fixture course holding one frozen registration per state and per error code.")
692 .course_id(STATES_COURSE_ID)
693 .role(teacher_user_id, UserRole::Teacher)
694 .instance(instance_config(cx.v5(b"instance:states")));
695 for (order, course_code) in STATES_COURSE_CODES.iter().enumerate() {
696 let mut module = ModuleBuilder::new()
697 .order(order as i32)
698 .ects(5.0)
699 .uh_course_code(course_code.to_string())
700 .credit_registration(CreditRegistrationSeed {
701 paused_reason: Some(
702 "Seeded fixture: these rows are read by the teacher and admin views and must not move."
703 .to_string(),
704 ),
705 ..credit_registration_config(course_code)
706 });
707 if order > 0 {
708 module = module.name(format!("Module {course_code}"));
709 }
710 course = course.module(module);
711 }
712 let (course, instance, _) = course.seed(conn, app_config, &cx).await?;
713 let seeded = SeededCourse {
714 course_id: course.id,
715 course_instance_id: instance.id,
716 };
717
718 let holders = [
719 students.get(&STUDENT_7)?,
720 students.get(&STUDENT_8)?,
721 students.get(&CREDIT_REGISTRATION_STUDENT_1)?,
722 students.get(&CREDIT_REGISTRATION_STUDENT_2)?,
723 students.get(&CREDIT_REGISTRATION_STUDENT_3)?,
724 students.get(&CREDIT_REGISTRATION_STUDENT_4)?,
725 students.get(&CREDIT_REGISTRATION_STUDENT_5)?,
726 ];
727 for holder in holders {
728 enroll(conn, holder.user_id, course.id, instance.id).await?;
729 }
730 let module_ids = module_ids_by_course_code(conn, course.id).await?;
731
732 let rows = CreditRegistrationState::ALL
734 .iter()
735 .map(|state| (*state, None))
736 .chain(
737 CreditRegistrationErrorCode::ALL
738 .iter()
739 .map(|code| (CreditRegistrationState::FailedPermanent, Some(*code))),
740 );
741 for (index, (state, error_code)) in rows.enumerate() {
742 let holder = holders[index % holders.len()];
743 let course_code = STATES_COURSE_CODES
744 .get(index / holders.len())
745 .ok_or_else(|| anyhow::anyhow!("the states grid has too few modules"))?;
746 let module_id = *module_ids
747 .get(*course_code)
748 .ok_or_else(|| anyhow::anyhow!("no states module has code {course_code}"))?;
749 seed_frozen_registration(conn, &cx, seeded, module_id, holder, state, error_code).await?;
750 }
751 Ok(())
752}
753
754async fn module_ids_by_course_code(
755 conn: &mut PgConnection,
756 course_id: Uuid,
757) -> Result<HashMap<String, Uuid>> {
758 Ok(course_modules::get_by_course_id(conn, course_id)
759 .await?
760 .into_iter()
761 .filter_map(|module| module.uh_course_code.map(|code| (code, module.id)))
762 .collect())
763}
764
765async fn seed_retry_course(
773 conn: &mut PgConnection,
774 app_config: &ApplicationConfiguration,
775 org: Uuid,
776 teacher_user_id: Uuid,
777 students: &LinkedStudents,
778) -> Result<()> {
779 let cx = SeedContext {
780 teacher: teacher_user_id,
781 org,
782 base_course_ns: RETRY_COURSE_ID,
783 };
784 let (course, instance, module) =
785 CourseBuilder::new("Credit registration retry", RETRY_COURSE_SLUG)
786 .desc("Fixture course holding the registrations a teacher retries, and the ones they cannot.")
787 .course_id(RETRY_COURSE_ID)
788 .role(teacher_user_id, UserRole::Teacher)
789 .instance(instance_config(cx.v5(b"instance:retry")))
790 .module(
791 ModuleBuilder::new()
792 .order(0)
793 .ects(5.0)
794 .uh_course_code(CRS_RETRY_101.to_string())
795 .credit_registration(CreditRegistrationSeed {
796 paused_reason: Some(
797 "Seeded fixture: the retry specs read these rows and the workers must not move them."
798 .to_string(),
799 ),
800 ..credit_registration_config(CRS_RETRY_101)
801 }),
802 )
803 .seed(conn, app_config, &cx)
804 .await?;
805 let seeded = SeededCourse {
806 course_id: course.id,
807 course_instance_id: instance.id,
808 };
809
810 for (fixture, state) in [
814 (
815 &CREDIT_REGISTRATION_STUDENT_1,
816 CreditRegistrationState::FailedPermanent,
817 ),
818 (
819 &CREDIT_REGISTRATION_STUDENT_2,
820 CreditRegistrationState::FailedPermanent,
821 ),
822 (
823 &CREDIT_REGISTRATION_STUDENT_3,
824 CreditRegistrationState::SubmissionUncertain,
825 ),
826 (
827 &CREDIT_REGISTRATION_STUDENT_4,
828 CreditRegistrationState::Cancelled,
829 ),
830 ] {
831 let holder = students.get(fixture)?;
832 enroll(conn, holder.user_id, course.id, instance.id).await?;
833 seed_frozen_registration(conn, &cx, seeded, module.id, holder, state, None).await?;
834 }
835 Ok(())
836}
837
838async fn seed_frozen_registration(
840 conn: &mut PgConnection,
841 cx: &SeedContext,
842 course: SeededCourse,
843 course_module_id: Uuid,
844 student: &SeededStudent,
845 state: CreditRegistrationState,
846 error_code: Option<CreditRegistrationErrorCode>,
847) -> Result<()> {
848 let completion_id =
849 seed_eligible_completion(conn, student, course_module_id, course.course_id, None).await?;
850 let id = credit_registrations::insert(
851 conn,
852 PKeyPolicy::Fixed(
853 cx.v5(format!("credit-registration:{}:{course_module_id}", student.email).as_bytes()),
854 ),
855 &NewCreditRegistration {
856 course_module_completion_id: completion_id,
857 user_id: student.user_id,
858 course_id: course.course_id,
859 course_module_id,
860 course_instance_id: course.course_instance_id,
861 attempt_number: 1,
862 },
863 Some("Seeded fixture"),
864 )
865 .await?;
866 credit_registrations::transition(
867 conn,
868 id,
869 &Transition {
870 error_code,
871 needs_admin_attention: error_code.map(|_| credit_registrations::AdminAttention::Raise),
872 ..Transition::planted(state)
873 },
874 )
875 .await?;
876 Ok(())
877}
878
879async fn seed_import_outcomes_course(
882 conn: &mut PgConnection,
883 app_config: &ApplicationConfiguration,
884 org: Uuid,
885 teacher_user_id: Uuid,
886 student: &SeededStudent,
887) -> Result<()> {
888 let cx = SeedContext {
889 teacher: teacher_user_id,
890 org,
891 base_course_ns: IMPORT_OUTCOMES_COURSE_ID,
892 };
893 let mut course = CourseBuilder::new(
894 "Credit registration import outcomes",
895 IMPORT_OUTCOMES_COURSE_SLUG,
896 )
897 .desc("Fixture course whose modules each provoke one Sisu import error.")
898 .course_id(IMPORT_OUTCOMES_COURSE_ID)
899 .instance(instance_config(cx.v5(b"instance:import-outcomes")));
900 for (order, course_code) in IMPORT_OUTCOME_COURSE_CODES.iter().enumerate() {
901 let mut module = ModuleBuilder::new()
902 .order(order as i32)
903 .ects(5.0)
904 .uh_course_code(course_code.to_string())
905 .credit_registration(credit_registration_config(course_code));
906 if order > 0 {
907 module = module.name(format!("Module {course_code}"));
908 }
909 course = course.module(module);
910 }
911 let (course, instance, _) = course.seed(conn, app_config, &cx).await?;
912 enroll(conn, student.user_id, course.id, instance.id).await?;
913 for module in course_modules::get_by_course_id(conn, course.id).await? {
914 seed_eligible_completion(conn, student, module.id, course.id, None).await?;
915 }
916 Ok(())
917}
918
919async fn seed_grade_improvement_course(
920 conn: &mut PgConnection,
921 app_config: &ApplicationConfiguration,
922 org: Uuid,
923 teacher_user_id: Uuid,
924 student: &SeededStudent,
925) -> Result<()> {
926 let cx = SeedContext {
927 teacher: teacher_user_id,
928 org,
929 base_course_ns: GRADE_IMPROVEMENT_COURSE_ID,
930 };
931 let (course, instance, module) = CourseBuilder::new(
932 "Credit registration grade improvement",
933 GRADE_IMPROVEMENT_COURSE_SLUG,
934 )
935 .desc("Fixture course whose module is graded rather than pass/fail.")
936 .course_id(GRADE_IMPROVEMENT_COURSE_ID)
937 .instance(instance_config(cx.v5(b"instance:grade-improvement")))
938 .module(
939 ModuleBuilder::new()
940 .order(0)
941 .ects(5.0)
942 .uh_course_code(CRS_GRADED_101.to_string())
943 .credit_registration(credit_registration_config(CRS_GRADED_101)),
944 )
945 .seed(conn, app_config, &cx)
946 .await?;
947 enroll(conn, student.user_id, course.id, instance.id).await?;
948 seed_eligible_completion(conn, student, module.id, course.id, Some(3)).await?;
949 Ok(())
950}
951
952async fn link_student_number(
953 conn: &mut PgConnection,
954 cx: &SeedContext,
955 fixture: &MockPersonFixture,
956 user_id: Uuid,
957 verified_via: StudentNumberVerificationMethod,
958) -> Result<()> {
959 let is_admin_manual = verified_via == StudentNumberVerificationMethod::AdminManual;
960 verified_student_numbers::insert(
961 conn,
962 PKeyPolicy::Fixed(cx.v5(format!("verified:{}", fixture.student_number).as_bytes())),
963 &NewVerifiedStudentNumber {
964 user_id,
965 student_number: DbSecret::new(fixture.student_number),
966 sisu_person_id: DbSecret::new(fixture.sisu_person_id()),
967 first_names: Some(DbSecret::new(fixture.first_names)),
968 last_name: Some(DbSecret::new(fixture.last_name)),
969 verified_via,
970 verified_via_email: (!is_admin_manual).then(|| DbSecret::new(fixture.sisu_email)),
971 linked_by_user_id: is_admin_manual.then_some(cx.teacher),
972 link_reason: is_admin_manual
973 .then(|| "Seeded fixture: the address Sisu holds rejects our mail.".to_string()),
974 verified_from_course_id: None,
975 },
976 )
977 .await?;
978 Ok(())
979}
980
981async fn enroll(
984 conn: &mut PgConnection,
985 user_id: Uuid,
986 course_id: Uuid,
987 course_instance_id: Uuid,
988) -> Result<()> {
989 course_instance_enrollments::insert_enrollment_and_set_as_current(
990 conn,
991 NewCourseInstanceEnrollment {
992 user_id,
993 course_id,
994 course_instance_id,
995 },
996 )
997 .await?;
998 user_ai_usage_notice_acknowledgements::acknowledge(conn, user_id, course_id).await?;
999 Ok(())
1000}
1001
1002async fn default_module_id(conn: &mut PgConnection, course_id: Uuid) -> Result<Uuid> {
1003 Ok(course_modules::get_default_by_course_id(conn, course_id)
1004 .await?
1005 .id)
1006}
1007
1008async fn seed_eligible_completion(
1011 conn: &mut PgConnection,
1012 student: &SeededStudent,
1013 course_module_id: Uuid,
1014 course_id: Uuid,
1015 grade: Option<i32>,
1016) -> Result<Uuid> {
1017 let completion_id = course_module_completions::insert_seed_row(
1018 conn,
1019 &NewCourseModuleCompletionSeed {
1020 course_id,
1021 course_module_id,
1022 user_id: student.user_id,
1023 completion_date: Some(Utc::now() - Duration::days(1)),
1024 completion_language: Some("en-US".to_string()),
1025 eligible_for_ects: Some(true),
1026 email: Some(student.email.clone()),
1027 grade,
1028 passed: Some(true),
1029 prerequisite_modules_completed: Some(true),
1030 needs_to_be_reviewed: Some(false),
1031 },
1032 )
1033 .await?;
1034 Ok(completion_id)
1035}
1036
1037async fn insert_student(
1038 conn: &mut PgConnection,
1039 user_id: Uuid,
1040 email: &str,
1041 first_name: &str,
1042 last_name: &str,
1043) -> Result<SeededStudent> {
1044 let user_id = users::insert(
1045 conn,
1046 PKeyPolicy::Fixed(user_id),
1047 email,
1048 Some(first_name),
1049 Some(last_name),
1050 )
1051 .await?;
1052 user_details::update_user_country(conn, user_id, "fi").await?;
1053 let password = email
1056 .split('@')
1057 .next()
1058 .expect("split always yields one element");
1059 let hash = hash_password(&SecretString::new(password.to_string().into()))
1060 .map_err(|e| anyhow::anyhow!("failed to hash a seeded password: {e}"))?;
1061 upsert_user_password(conn, user_id, &hash).await?;
1062 Ok(SeededStudent {
1063 user_id,
1064 email: email.to_string(),
1065 })
1066}
1067
1068async fn seed_linking_tokens(
1071 conn: &mut PgConnection,
1072 cx: &SeedContext,
1073 course_id: Uuid,
1074 claimed_by_user_id: Uuid,
1075) -> Result<()> {
1076 let now = Utc::now();
1077 student_number_verification_tokens::insert_seed_row(
1078 conn,
1079 PKeyPolicy::Fixed(cx.v5(b"linking-token:valid")),
1080 &SeedStudentNumberVerificationToken {
1081 token: LINKING_TOKEN_VALID.to_string(),
1082 student_number: LINK_VALID.student_number.to_string(),
1083 sisu_person_id: LINK_VALID.sisu_person_id(),
1084 first_names: Some(LINK_VALID.first_names.to_string()),
1085 last_name: Some(LINK_VALID.last_name.to_string()),
1086 emailed_to: LINK_VALID.sisu_email.to_string(),
1087 course_id: Some(course_id),
1088 expires_at: now + Duration::days(14),
1089 used_at: None,
1090 claimed_by_user_id: None,
1091 },
1092 )
1093 .await?;
1094 student_number_verification_tokens::insert_seed_row(
1095 conn,
1096 PKeyPolicy::Fixed(cx.v5(b"linking-token:expired")),
1097 &SeedStudentNumberVerificationToken {
1098 token: LINKING_TOKEN_EXPIRED.to_string(),
1099 student_number: LINK_EXPIRED.student_number.to_string(),
1100 sisu_person_id: LINK_EXPIRED.sisu_person_id(),
1101 first_names: Some(LINK_EXPIRED.first_names.to_string()),
1102 last_name: Some(LINK_EXPIRED.last_name.to_string()),
1103 emailed_to: LINK_EXPIRED.sisu_email.to_string(),
1104 course_id: Some(course_id),
1105 expires_at: now - Duration::days(1),
1106 used_at: None,
1107 claimed_by_user_id: None,
1108 },
1109 )
1110 .await?;
1111 student_number_verification_tokens::insert_seed_row(
1112 conn,
1113 PKeyPolicy::Fixed(cx.v5(b"linking-token:already-used")),
1114 &SeedStudentNumberVerificationToken {
1115 token: LINKING_TOKEN_ALREADY_USED.to_string(),
1116 student_number: LINK_USED.student_number.to_string(),
1117 sisu_person_id: LINK_USED.sisu_person_id(),
1118 first_names: Some(LINK_USED.first_names.to_string()),
1119 last_name: Some(LINK_USED.last_name.to_string()),
1120 emailed_to: LINK_USED.sisu_email.to_string(),
1121 course_id: Some(course_id),
1122 expires_at: now + Duration::days(14),
1123 used_at: Some(now - Duration::hours(1)),
1124 claimed_by_user_id: Some(claimed_by_user_id),
1125 },
1126 )
1127 .await?;
1128 student_number_verification_tokens::insert_seed_row(
1129 conn,
1130 PKeyPolicy::Fixed(cx.v5(b"linking-token:conflict")),
1131 &SeedStudentNumberVerificationToken {
1132 token: LINKING_TOKEN_CONFLICT.to_string(),
1133 student_number: STUDENT_6.student_number.to_string(),
1134 sisu_person_id: STUDENT_6.sisu_person_id(),
1135 first_names: Some(STUDENT_6.first_names.to_string()),
1136 last_name: Some(STUDENT_6.last_name.to_string()),
1137 emailed_to: STUDENT_6.sisu_email.to_string(),
1138 course_id: Some(course_id),
1139 expires_at: now + Duration::days(14),
1140 used_at: None,
1141 claimed_by_user_id: None,
1142 },
1143 )
1144 .await?;
1145 Ok(())
1146}
1147
1148async fn seed_superseded_attempt_pair(
1151 conn: &mut PgConnection,
1152 student: &SeededStudent,
1153 course: SeededCourse,
1154) -> Result<()> {
1155 let course_module_id = default_module_id(conn, course.course_id).await?;
1156 let completion_id = course_module_completions::insert_seed_row(
1157 conn,
1158 &NewCourseModuleCompletionSeed {
1159 course_id: course.course_id,
1160 course_module_id,
1161 user_id: student.user_id,
1162 completion_date: Some(Utc::now() - Duration::days(20)),
1163 completion_language: Some("en-US".to_string()),
1164 eligible_for_ects: Some(true),
1165 email: Some(student.email.clone()),
1166 grade: Some(4),
1167 passed: Some(true),
1168 prerequisite_modules_completed: Some(true),
1169 needs_to_be_reviewed: Some(false),
1170 },
1171 )
1172 .await?;
1173
1174 let mut tx = conn.begin().await?;
1177 let attempt_1 = insert_registered_attempt(
1178 &mut tx,
1179 SUPERSEDED_ATTEMPT_1_ID,
1180 completion_id,
1181 student.user_id,
1182 course,
1183 course_module_id,
1184 1,
1185 "3",
1186 )
1187 .await?;
1188 credit_registrations::mark_superseded(&mut tx, attempt_1, SUPERSEDED_ATTEMPT_2_ID).await?;
1189 insert_registered_attempt(
1190 &mut tx,
1191 SUPERSEDED_ATTEMPT_2_ID,
1192 completion_id,
1193 student.user_id,
1194 course,
1195 course_module_id,
1196 2,
1197 "4",
1198 )
1199 .await?;
1200 tx.commit().await?;
1201 Ok(())
1202}
1203
1204#[allow(clippy::too_many_arguments)]
1205async fn insert_registered_attempt(
1206 conn: &mut PgConnection,
1207 id: Uuid,
1208 course_module_completion_id: Uuid,
1209 user_id: Uuid,
1210 course: SeededCourse,
1211 course_module_id: Uuid,
1212 attempt_number: i32,
1213 grade_id: &str,
1214) -> Result<Uuid> {
1215 let id = credit_registrations::insert(
1216 conn,
1217 PKeyPolicy::Fixed(id),
1218 &NewCreditRegistration {
1219 course_module_completion_id,
1220 user_id,
1221 course_id: course.course_id,
1222 course_module_id,
1223 course_instance_id: course.course_instance_id,
1224 attempt_number,
1225 },
1226 Some("Seeded fixture"),
1227 )
1228 .await?;
1229 credit_registrations::set_payload_snapshot(
1230 conn,
1231 id,
1232 &PayloadSnapshot {
1233 student_number: DbSecret::new(STUDENT_6.student_number),
1234 sisu_person_id: Some(DbSecret::new(STUDENT_6.sisu_person_id())),
1235 uh_course_code: CRS_101.to_string(),
1236 selected_enrolment_id: Some(format!("otm-{}-degree", STUDENT_6.student_number)),
1237 selected_enrolment_kind: Some("degree".to_string()),
1238 selected_enrolment_realisation_id: Some("hy-opt-cur-090009011".to_string()),
1239 selected_enrolment_realisation_name: Some(serde_json::json!({
1240 "fi": "Rekisteröinnin testitoteutus",
1241 "en": "Registration test realisation",
1242 "sv": null,
1243 })),
1244 attained_at: Utc::now() - Duration::days(20),
1245 attainment_language: "en".to_string(),
1246 grade_scale_id: "sis-0-5".to_string(),
1247 grade_id: grade_id.to_string(),
1248 credits: 5.0,
1249 },
1250 )
1251 .await?;
1252 credit_registrations::transition(
1253 conn,
1254 id,
1255 &Transition::planted(CreditRegistrationState::Registered),
1256 )
1257 .await?;
1258 Ok(id)
1259}
1260
1261async fn seed_admin_actions(
1264 conn: &mut PgConnection,
1265 cx: &SeedContext,
1266 course_id: Uuid,
1267 teacher_user_id: Uuid,
1268) -> Result<()> {
1269 let admin_user_id = headless_lms_models::users::get_by_email(conn, "admin@example.com")
1270 .await?
1271 .id;
1272 credit_registration_admin_actions::record(
1273 conn,
1274 &NewCreditRegistrationAdminAction {
1275 target_id: Some(SUPERSEDED_ATTEMPT_1_ID),
1276 reason: Some("Seeded fixture: checked Sisu by hand and requeued".to_string()),
1277 before_state: Some(CreditRegistrationState::SubmissionUncertain),
1278 after_state: Some(CreditRegistrationState::Registered),
1279 affected_row_count: Some(1),
1280 ..NewCreditRegistrationAdminAction::new(
1281 CreditRegistrationAdminAction::TransitionItem,
1282 CreditRegistrationAdminActionTarget::CreditRegistration,
1283 admin_user_id,
1284 GLOBAL_ADMIN_ROLE,
1285 )
1286 },
1287 )
1288 .await?;
1289 credit_registration_admin_actions::record(
1290 conn,
1291 &NewCreditRegistrationAdminAction {
1292 target_id: Some(cx.v5(b"linking-token:valid")),
1293 actor_course_id: Some(course_id),
1294 reason: Some("Seeded fixture: student reported the mail never arrived".to_string()),
1295 affected_row_count: Some(1),
1296 ..NewCreditRegistrationAdminAction::new(
1297 CreditRegistrationAdminAction::ResendLinkEmail,
1298 CreditRegistrationAdminActionTarget::StudentNumberVerificationToken,
1299 teacher_user_id,
1300 COURSE_TEACHER_ROLE,
1301 )
1302 },
1303 )
1304 .await?;
1305 Ok(())
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310 use super::*;
1311
1312 #[test]
1315 fn seeded_linking_tokens_are_long_enough() {
1316 for token in [
1317 LINKING_TOKEN_VALID,
1318 LINKING_TOKEN_EXPIRED,
1319 LINKING_TOKEN_ALREADY_USED,
1320 LINKING_TOKEN_CONFLICT,
1321 ] {
1322 assert!(token.len() >= 128, "token too short: {}", token.len());
1323 }
1324 }
1325}