1use anyhow::Result;
25use chrono::{Duration, Utc};
26use headless_lms_models::{
27 PKeyPolicy, course_credit_registration_consents, course_instance_enrollments,
28 course_module_completions::{self, NewCourseModuleCompletionSeed},
29 credit_registration_admin_actions::{
30 self, CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
31 NewCreditRegistrationAdminAction,
32 },
33 credit_registrations::{
34 self, CreditRegistrationState, NewCreditRegistration, PayloadSnapshot, Transition,
35 },
36 student_number_verification_tokens::{self, SeedStudentNumberVerificationToken},
37 study_registry_registrars::get_or_create_default_registrar,
38 user_details::{self, EmailVerificationMethod},
39 user_passwords::{hash_password, upsert_user_password},
40 users,
41 verified_student_numbers::{self, NewVerifiedStudentNumber, StudentNumberVerificationMethod},
42};
43use secrecy::SecretString;
44use sqlx::PgConnection;
45use tracing::info;
46use uuid::Uuid;
47
48use crate::programs::seed::builder::{
49 chapter::ChapterBuilder,
50 context::SeedContext,
51 course::{CourseBuilder, CourseInstanceConfig},
52 module::{CompletionBuilder, CompletionRegisteredBuilder, ModuleBuilder},
53 page::PageBuilder,
54};
55use crate::programs::seed::seed_courses::CommonCourseData;
56use crate::programs::seed::seed_helpers::paragraph;
57
58pub const SUOTAR_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0001_4a5e_9e6e_c0de00000001);
60pub const OLD_FLOW_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0002_4a5e_9e6e_c0de00000002);
62pub const BACKFILL_COURSE_ID: Uuid = Uuid::from_u128(0xc5ed17ea_0003_4a5e_9e6e_c0de00000003);
64
65pub const LINKING_TOKEN_VALID: &str = concat!(
70 "11111111-1111-1111-1111-111111111111",
71 "11111111-1111-1111-1111-111111111111",
72 "11111111-1111-1111-1111-111111111111",
73 "11111111-1111-1111-1111-111111111111",
74);
75pub const LINKING_TOKEN_EXPIRED: &str = concat!(
76 "22222222-2222-2222-2222-222222222222",
77 "22222222-2222-2222-2222-222222222222",
78 "22222222-2222-2222-2222-222222222222",
79 "22222222-2222-2222-2222-222222222222",
80);
81pub const LINKING_TOKEN_ALREADY_USED: &str = concat!(
82 "33333333-3333-3333-3333-333333333333",
83 "33333333-3333-3333-3333-333333333333",
84 "33333333-3333-3333-3333-333333333333",
85 "33333333-3333-3333-3333-333333333333",
86);
87
88struct SeededStudent {
90 user_id: Uuid,
91 email: String,
92}
93
94pub async fn seed_credit_registration(common_course_data: CommonCourseData) -> Result<Uuid> {
95 let CommonCourseData {
96 db_pool,
97 organization_id: org,
98 teacher_user_id,
99 ..
100 } = common_course_data;
101
102 let mut conn = db_pool.acquire().await?;
103 let cx = SeedContext {
104 teacher: teacher_user_id,
105 org,
106 base_course_ns: SUOTAR_COURSE_ID,
107 };
108
109 info!("inserting credit registration courses");
110
111 let suotar_instance_id = cx.v5(b"instance:suotar");
112 let (suotar_course, suotar_instance, _) = CourseBuilder::new(
113 "Credit registration via Suotar",
114 "credit-registration-via-suotar",
115 )
116 .desc("Fixture course for the credit registration system tests. The Suotar flag is off.")
117 .course_id(SUOTAR_COURSE_ID)
118 .instance(instance_config(suotar_instance_id))
119 .module(
120 ModuleBuilder::new()
121 .order(0)
122 .ects(5.0)
123 .uh_course_code("CRS-101".to_string())
124 .chapter(
125 ChapterBuilder::new(1, "Registering credits")
127 .opens(Utc::now())
128 .fixed_ids(cx.v5(b"chapter:1"), cx.v5(b"chapter:1:front-page"))
129 .page(
130 PageBuilder::new("/chapter-1/page-1", "How registration works").block(
131 paragraph(
132 "Completing this module registers credits into Sisu.",
133 cx.v5(b"page:1:1:block"),
134 ),
135 ),
136 ),
137 ),
138 )
139 .module(
140 ModuleBuilder::new()
141 .order(1)
142 .name("Second module")
143 .ects(3.0)
144 .uh_course_code("CRS-102".to_string()),
145 )
146 .seed(&mut conn, &cx)
147 .await?;
148
149 let old_flow_cx = SeedContext {
150 teacher: teacher_user_id,
151 org,
152 base_course_ns: OLD_FLOW_COURSE_ID,
153 };
154 CourseBuilder::new(
155 "Credit registration old flow",
156 "credit-registration-old-flow",
157 )
158 .desc("Fixture course left on the legacy open university registration flow.")
159 .course_id(OLD_FLOW_COURSE_ID)
160 .instance(instance_config(old_flow_cx.v5(b"instance:old-flow")))
161 .module(
162 ModuleBuilder::new()
163 .order(0)
164 .ects(5.0)
165 .uh_course_code("CRS-OLD-101".to_string())
166 .register_to_open_university(true),
167 )
168 .seed(&mut conn, &old_flow_cx)
169 .await?;
170
171 seed_backfill_course(&mut conn, org, teacher_user_id).await?;
172
173 info!("inserting credit registration students");
174
175 let consented_linked = insert_student(
176 &mut conn,
177 cx.v5(b"user:consented-linked"),
178 "credit-registration-consented-linked@example.com",
179 "Zzyzx",
180 "Happypath",
181 )
182 .await?;
183 let consented_unlinked = insert_student(
184 &mut conn,
185 cx.v5(b"user:consented-unlinked"),
186 "credit-registration-consented-unlinked@example.com",
187 "Zzyzx",
188 "Linkpending",
189 )
190 .await?;
191 let not_consented = insert_student(
192 &mut conn,
193 cx.v5(b"user:not-consented"),
194 "credit-registration-not-consented@example.com",
195 "Zzyzx",
196 "Noconsent",
197 )
198 .await?;
199 let verified_email = insert_student(
200 &mut conn,
201 cx.v5(b"user:verified-email"),
202 "credit-registration-verified-email@example.com",
203 "Zzyzx",
204 "Fasttrack",
205 )
206 .await?;
207 let unverified_twin = insert_student(
208 &mut conn,
209 cx.v5(b"user:unverified-twin"),
210 "credit-registration-unverified-twin@example.com",
211 "Zzyzx",
212 "Nearmiss",
213 )
214 .await?;
215 let superseded_student = insert_student(
216 &mut conn,
217 cx.v5(b"user:superseded-attempts"),
218 "credit-registration-superseded@example.com",
219 "Zzyzx",
220 "Regraded",
221 )
222 .await?;
223
224 for student in [
225 &consented_linked,
226 &consented_unlinked,
227 ¬_consented,
228 &verified_email,
229 &unverified_twin,
230 &superseded_student,
231 ] {
232 course_instance_enrollments::insert(
233 &mut conn,
234 student.user_id,
235 suotar_course.id,
236 suotar_instance.id,
237 )
238 .await?;
239 }
240
241 for student in [&consented_linked, &consented_unlinked, &verified_email] {
244 course_credit_registration_consents::upsert(
245 &mut conn,
246 student.user_id,
247 suotar_course.id,
248 true,
249 )
250 .await?;
251 }
252 course_credit_registration_consents::upsert(
253 &mut conn,
254 superseded_student.user_id,
255 suotar_course.id,
256 true,
257 )
258 .await?;
259
260 verified_student_numbers::insert(
261 &mut conn,
262 PKeyPolicy::Fixed(cx.v5(b"verified-student-number:consented-linked")),
263 &NewVerifiedStudentNumber {
264 user_id: consented_linked.user_id,
265 student_number: "900000101".to_string(),
266 sisu_person_id: "hy-hlo-900000101".to_string(),
267 first_names: Some("Zzyzx".to_string()),
268 last_name: Some("Happypath".to_string()),
269 verified_via: StudentNumberVerificationMethod::EmailedLink,
270 verified_via_email: Some("zzyzx.happypath@helsinki.example".to_string()),
271 verified_via_email_match_field: None,
272 account_email_verified_at: None,
273 linked_by_user_id: None,
274 link_reason: None,
275 verified_from_course_id: Some(suotar_course.id),
276 },
277 )
278 .await?;
279 verified_student_numbers::insert(
280 &mut conn,
281 PKeyPolicy::Fixed(cx.v5(b"verified-student-number:superseded")),
282 &NewVerifiedStudentNumber {
283 user_id: superseded_student.user_id,
284 student_number: "900000901".to_string(),
285 sisu_person_id: "hy-hlo-900000901".to_string(),
286 first_names: Some("Zzyzx".to_string()),
287 last_name: Some("Regraded".to_string()),
288 verified_via: StudentNumberVerificationMethod::EmailedLink,
289 verified_via_email: Some("zzyzx.regraded@helsinki.example".to_string()),
290 verified_via_email_match_field: None,
291 account_email_verified_at: None,
292 linked_by_user_id: None,
293 link_reason: None,
294 verified_from_course_id: Some(suotar_course.id),
295 },
296 )
297 .await?;
298
299 user_details::set_email_verified(
303 &mut conn,
304 verified_email.user_id,
305 EmailVerificationMethod::EmailedCode,
306 Utc::now() - Duration::days(30),
307 )
308 .await?;
309
310 info!("inserting credit registration linking tokens");
311 seed_linking_tokens(&mut conn, &cx, suotar_course.id, unverified_twin.user_id).await?;
312
313 info!("inserting credit registration ledger history");
314 seed_superseded_attempt_pair(
315 &mut conn,
316 &cx,
317 &superseded_student,
318 suotar_course.id,
319 suotar_instance.id,
320 )
321 .await?;
322
323 info!("inserting credit registration admin actions");
324 seed_admin_actions(&mut conn, &cx, suotar_course.id, teacher_user_id).await?;
325
326 Ok(SUOTAR_COURSE_ID)
327}
328
329fn instance_config(instance_id: Uuid) -> CourseInstanceConfig {
330 CourseInstanceConfig {
331 name: None,
332 description: None,
333 support_email: None,
334 teacher_in_charge_name: "admin".to_string(),
335 teacher_in_charge_email: "admin@example.com".to_string(),
336 opening_time: None,
337 closing_time: None,
338 instance_id: Some(instance_id),
339 }
340}
341
342async fn seed_backfill_course(
345 conn: &mut PgConnection,
346 org: Uuid,
347 teacher_user_id: Uuid,
348) -> Result<()> {
349 let cx = SeedContext {
350 teacher: teacher_user_id,
351 org,
352 base_course_ns: BACKFILL_COURSE_ID,
353 };
354 let registrar_id = get_or_create_default_registrar(conn).await?;
355
356 let mut module = ModuleBuilder::new()
357 .order(0)
358 .ects(5.0)
359 .uh_course_code("CRS-BACKFILL-101".to_string())
360 .default_registrar(registrar_id);
361
362 for index in 1..=4 {
363 let student = insert_student(
364 conn,
365 cx.v5(format!("user:backfill:{index}").as_bytes()),
366 &format!("credit-registration-backfill-{index}@example.com"),
367 "Zzyzx",
368 &format!("Backfill{index}"),
369 )
370 .await?;
371 let mut completion = CompletionBuilder::new(student.user_id)
372 .email(student.email.clone())
373 .grade(3)
374 .passed(true);
375 if index == 1 {
376 completion = completion.registered(
377 CompletionRegisteredBuilder::new().real_student_number(format!("90000110{index}")),
378 );
379 }
380 module = module.completion(completion);
381 }
382
383 let (course, instance, _) = CourseBuilder::new(
384 "Credit registration backfill",
385 "credit-registration-backfill",
386 )
387 .desc("Fixture course with pre-existing passed completions, for the backfill-on-opt-in spec.")
388 .course_id(BACKFILL_COURSE_ID)
389 .instance(instance_config(cx.v5(b"instance:backfill")))
390 .module(module)
391 .seed(conn, &cx)
392 .await?;
393
394 for index in 1..=4 {
395 let user_id = cx.v5(format!("user:backfill:{index}").as_bytes());
396 course_instance_enrollments::insert(conn, user_id, course.id, instance.id).await?;
397 course_credit_registration_consents::upsert(conn, user_id, course.id, true).await?;
398 }
399 Ok(())
400}
401
402async fn insert_student(
403 conn: &mut PgConnection,
404 user_id: Uuid,
405 email: &str,
406 first_name: &str,
407 last_name: &str,
408) -> Result<SeededStudent> {
409 let user_id = users::insert(
410 conn,
411 PKeyPolicy::Fixed(user_id),
412 email,
413 Some(first_name),
414 Some(last_name),
415 )
416 .await?;
417 user_details::update_user_country(conn, user_id, "fi").await?;
418 let password = email
421 .split('@')
422 .next()
423 .expect("split always yields one element");
424 let hash = hash_password(&SecretString::new(password.to_string().into()))
425 .map_err(|e| anyhow::anyhow!("failed to hash a seeded password: {e}"))?;
426 upsert_user_password(conn, user_id, &hash).await?;
427 Ok(SeededStudent {
428 user_id,
429 email: email.to_string(),
430 })
431}
432
433async fn seed_linking_tokens(
436 conn: &mut PgConnection,
437 cx: &SeedContext,
438 course_id: Uuid,
439 claimed_by_user_id: Uuid,
440) -> Result<()> {
441 let now = Utc::now();
442 student_number_verification_tokens::insert_seed_row(
443 conn,
444 PKeyPolicy::Fixed(cx.v5(b"linking-token:valid")),
445 &SeedStudentNumberVerificationToken {
446 token: LINKING_TOKEN_VALID.to_string(),
447 student_number: "900000201".to_string(),
448 sisu_person_id: "hy-hlo-900000201".to_string(),
449 first_names: Some("Zzyzx".to_string()),
450 last_name: Some("Linkvalid".to_string()),
451 emailed_to: "zzyzx.linkvalid@helsinki.example".to_string(),
452 course_id: Some(course_id),
453 expires_at: now + Duration::days(14),
454 used_at: None,
455 claimed_by_user_id: None,
456 },
457 )
458 .await?;
459 student_number_verification_tokens::insert_seed_row(
460 conn,
461 PKeyPolicy::Fixed(cx.v5(b"linking-token:expired")),
462 &SeedStudentNumberVerificationToken {
463 token: LINKING_TOKEN_EXPIRED.to_string(),
464 student_number: "900000202".to_string(),
465 sisu_person_id: "hy-hlo-900000202".to_string(),
466 first_names: Some("Zzyzx".to_string()),
467 last_name: Some("Linkexpired".to_string()),
468 emailed_to: "zzyzx.linkexpired@helsinki.example".to_string(),
469 course_id: Some(course_id),
470 expires_at: now - Duration::days(1),
471 used_at: None,
472 claimed_by_user_id: None,
473 },
474 )
475 .await?;
476 student_number_verification_tokens::insert_seed_row(
477 conn,
478 PKeyPolicy::Fixed(cx.v5(b"linking-token:already-used")),
479 &SeedStudentNumberVerificationToken {
480 token: LINKING_TOKEN_ALREADY_USED.to_string(),
481 student_number: "900000203".to_string(),
482 sisu_person_id: "hy-hlo-900000203".to_string(),
483 first_names: Some("Zzyzx".to_string()),
484 last_name: Some("Linkused".to_string()),
485 emailed_to: "zzyzx.linkused@helsinki.example".to_string(),
486 course_id: Some(course_id),
487 expires_at: now + Duration::days(14),
488 used_at: Some(now - Duration::hours(1)),
489 claimed_by_user_id: Some(claimed_by_user_id),
490 },
491 )
492 .await?;
493 Ok(())
494}
495
496async fn seed_superseded_attempt_pair(
499 conn: &mut PgConnection,
500 cx: &SeedContext,
501 student: &SeededStudent,
502 course_id: Uuid,
503 course_instance_id: Uuid,
504) -> Result<()> {
505 let course_module_id =
506 headless_lms_models::course_modules::get_default_by_course_id(conn, course_id)
507 .await?
508 .id;
509 let completion_id = course_module_completions::insert_seed_row(
510 conn,
511 &NewCourseModuleCompletionSeed {
512 course_id,
513 course_module_id,
514 user_id: student.user_id,
515 completion_date: Some(Utc::now() - Duration::days(20)),
516 completion_language: Some("en-US".to_string()),
517 eligible_for_ects: Some(true),
518 email: Some(student.email.clone()),
519 grade: Some(4),
520 passed: Some(true),
521 prerequisite_modules_completed: Some(true),
522 needs_to_be_reviewed: Some(false),
523 },
524 )
525 .await?;
526
527 let attempt_1 = insert_registered_attempt(
528 conn,
529 cx.v5(b"credit-registration:superseded:attempt-1"),
530 completion_id,
531 student.user_id,
532 course_id,
533 course_module_id,
534 course_instance_id,
535 1,
536 "3",
537 )
538 .await?;
539 credit_registrations::mark_superseded(conn, attempt_1, attempt_1).await?;
543 let attempt_2 = insert_registered_attempt(
544 conn,
545 cx.v5(b"credit-registration:superseded:attempt-2"),
546 completion_id,
547 student.user_id,
548 course_id,
549 course_module_id,
550 course_instance_id,
551 2,
552 "4",
553 )
554 .await?;
555 credit_registrations::mark_superseded(conn, attempt_1, attempt_2).await?;
556 Ok(())
557}
558
559#[allow(clippy::too_many_arguments)]
560async fn insert_registered_attempt(
561 conn: &mut PgConnection,
562 id: Uuid,
563 course_module_completion_id: Uuid,
564 user_id: Uuid,
565 course_id: Uuid,
566 course_module_id: Uuid,
567 course_instance_id: Uuid,
568 attempt_number: i32,
569 grade_id: &str,
570) -> Result<Uuid> {
571 let id = credit_registrations::insert(
572 conn,
573 PKeyPolicy::Fixed(id),
574 &NewCreditRegistration {
575 course_module_completion_id,
576 user_id,
577 course_id,
578 course_module_id,
579 course_instance_id,
580 attempt_number,
581 },
582 Some("Seeded fixture"),
583 )
584 .await?;
585 credit_registrations::set_payload_snapshot(
586 conn,
587 id,
588 &PayloadSnapshot {
589 student_number: "900000901".to_string(),
590 sisu_person_id: "hy-hlo-900000901".to_string(),
591 uh_course_code: "CRS-101".to_string(),
592 selected_enrolment_id: Some("otm-900000901-degree".to_string()),
593 selected_enrolment_kind: Some("degree".to_string()),
594 selected_enrolment_realisation_id: Some("hy-opt-cur-900000901".to_string()),
595 attainment_date: (Utc::now() - Duration::days(20)).date_naive(),
596 attainment_language: "en".to_string(),
597 grade_scale_id: "sis-0-5".to_string(),
598 grade_id: grade_id.to_string(),
599 credits: 5.0,
600 },
601 )
602 .await?;
603 credit_registrations::transition(
604 conn,
605 id,
606 &Transition::to(CreditRegistrationState::Registered),
607 )
608 .await?;
609 Ok(id)
610}
611
612async fn seed_admin_actions(
615 conn: &mut PgConnection,
616 cx: &SeedContext,
617 course_id: Uuid,
618 teacher_user_id: Uuid,
619) -> Result<()> {
620 let admin_user_id = headless_lms_models::users::get_by_email(conn, "admin@example.com")
621 .await?
622 .id;
623 credit_registration_admin_actions::record(
624 conn,
625 &NewCreditRegistrationAdminAction {
626 action: CreditRegistrationAdminAction::TransitionItem,
627 target_kind: CreditRegistrationAdminActionTarget::CreditRegistration,
628 target_id: Some(cx.v5(b"credit-registration:superseded:attempt-1")),
629 target_phase: None,
630 actor_user_id: admin_user_id,
631 actor_role: "global_admin".to_string(),
632 actor_course_id: None,
633 reason: Some("Seeded fixture: checked Sisu by hand and requeued".to_string()),
634 before_state: Some(CreditRegistrationState::SubmissionUncertain),
635 after_state: Some(CreditRegistrationState::Registered),
636 details: None,
637 affected_row_count: Some(1),
638 },
639 )
640 .await?;
641 credit_registration_admin_actions::record(
642 conn,
643 &NewCreditRegistrationAdminAction {
644 action: CreditRegistrationAdminAction::ResendLinkEmail,
645 target_kind: CreditRegistrationAdminActionTarget::StudentNumberVerificationToken,
646 target_id: Some(cx.v5(b"linking-token:valid")),
647 target_phase: None,
648 actor_user_id: teacher_user_id,
649 actor_role: "course_teacher".to_string(),
650 actor_course_id: Some(course_id),
651 reason: Some("Seeded fixture: student reported the mail never arrived".to_string()),
652 before_state: None,
653 after_state: None,
654 details: None,
655 affected_row_count: Some(1),
656 },
657 )
658 .await?;
659 Ok(())
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
669 fn seeded_linking_tokens_are_long_enough() {
670 for token in [
671 LINKING_TOKEN_VALID,
672 LINKING_TOKEN_EXPIRED,
673 LINKING_TOKEN_ALREADY_USED,
674 ] {
675 assert!(token.len() >= 128, "token too short: {}", token.len());
676 }
677 }
678}