1mod actions;
17mod enrolment_recheck;
18mod export;
19mod retry;
20
21use headless_lms_models::course_modules::CourseModuleCreditRegistrationConfig;
22use headless_lms_models::credit_registration_admin_actions::{
23 COURSE_TEACHER_ROLE, CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget,
24 NewCreditRegistrationAdminAction,
25};
26use headless_lms_models::credit_registration_events::{
27 CreditRegistrationEventKind, NotImprovedAttainment,
28};
29use headless_lms_models::credit_registrations::{
30 CourseModuleStateCount, CreditRegistrationErrorCode, CreditRegistrationState,
31 ResubmissionRefusal, ResubmissionStrictness, TeacherCreditRegistration,
32 TeacherCreditRegistrationFilters,
33};
34use headless_lms_models::email_deliveries::{EmailSendStatus, EmailSendStatusReport};
35use headless_lms_models::library::credit_registration::account_linking::MAX_LINKING_MAILS_PER_PERSON_AND_COURSE;
36use headless_lms_models::library::credit_registration::student_notifications;
37use headless_lms_models::library::credit_registration::{
38 PendingPreconditions, StudentFacingCreditRegistrationStatus,
39};
40use headless_lms_models::verified_student_numbers::StudentNumberVerificationMethod;
41use headless_lms_models::{
42 credit_registration_account_linking_emails::{self, CreditRegistrationAccountLinkingEmail},
43 verified_student_numbers,
44};
45use headless_lms_utils::secret_string::expose_option;
46use secrecy::{ExposeSecret, SecretString};
47use std::collections::HashMap;
48use utoipa::{OpenApi, ToSchema};
49
50use crate::domain::credit_registration::linking_mail_resend::{
51 ResendOutcome, ensure_resend_possible,
52};
53use crate::prelude::*;
54use headless_lms_base::config::ApplicationConfiguration;
55use headless_lms_credit_registration::account_linking::{
56 ManualActionContext, resend_linking_mail_for_target,
57};
58use headless_lms_utils::services::suotar::SuotarClient;
59
60use crate::domain::credit_registration::enrolment_recheck::can_request_enrolment_recheck;
61use crate::domain::credit_registration::mail_status::{NotificationEmailStatus, mask_email};
62
63pub(crate) async fn authorize_credit_registration_teacher(
66 conn: &mut PgConnection,
67 user_id: Uuid,
68 course_id: Uuid,
69) -> Result<crate::domain::authorization::AuthorizationToken, ControllerError> {
70 authorize(
71 conn,
72 Act::ViewAndManageCreditRegistrations,
73 Some(user_id),
74 Res::Course(course_id),
75 )
76 .await
77 .map_err(Into::into)
78}
79
80const MAX_TEACHER_RESENDS_PER_HOUR: i64 = 20;
82
83const RESEND_CALLER: &str = "teacher-resend";
85
86const MAX_USER_IDS_PER_REQUEST: usize = 500;
89
90const MAX_ROWS_PER_REQUEST: i64 = 2_000;
94
95#[derive(OpenApi)]
96#[openapi(paths(
97 get_course_credit_registration_module_configs,
98 get_course_credit_registration_summary,
99 get_course_credit_registrations_for_users,
100 get_course_credit_registrations,
101 get_credit_registration_details,
102 resend_course_credit_registration_linking_email,
103 retry::retry_credit_registration,
104 retry::retry_failed_credit_registrations_for_course,
105 enrolment_recheck::recheck_credit_registration_enrolment,
106 actions::get_course_credit_registration_actions,
107 export::export_course_credit_registrations
108))]
109pub(crate) struct MainFrontendCourseCreditRegistrationsApiDoc;
110
111#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
113pub struct TeacherLinkingEmailStatus {
114 pub email_send_status: EmailSendStatus,
115 pub sent_at: Option<DateTime<Utc>>,
116 pub last_attempt_at: Option<DateTime<Utc>>,
117 pub retry_count: i32,
118 pub next_retry_at: Option<DateTime<Utc>>,
119 pub emailed_to_masked: String,
120}
121
122#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
123pub struct CourseCreditRegistration {
124 pub id: Uuid,
125 pub user_id: Uuid,
126 pub first_name: Option<String>,
127 pub last_name: Option<String>,
128 pub email: Option<String>,
129 pub course_id: Uuid,
130 pub course_module_id: Uuid,
131 pub course_module_name: Option<String>,
132 pub course_instance_id: Uuid,
133 pub course_module_completion_id: Uuid,
134 pub completion_date: DateTime<Utc>,
135 pub state: CreditRegistrationState,
136 pub state_entered_at: DateTime<Utc>,
137 pub student_facing_status: StudentFacingCreditRegistrationStatus,
139 pub error_code: Option<CreditRegistrationErrorCode>,
140 pub needs_admin_attention: bool,
141 pub next_attempt_at: DateTime<Utc>,
142 pub registered_at: Option<DateTime<Utc>>,
143 pub sisu_attainment_id: Option<String>,
144 pub grade_id: Option<String>,
145 pub credits: Option<f32>,
146 pub attempt_number: i32,
147 pub superseded: bool,
148 pub resubmission_refusal: Option<ResubmissionRefusal>,
151 pub can_request_enrolment_recheck: bool,
154 pub student_number: Option<String>,
156 pub student_number_verified_at: Option<DateTime<Utc>>,
157 pub student_number_verified_via: Option<StudentNumberVerificationMethod>,
159 pub enrolment_realisation_name: Option<String>,
160 pub linking_email: Option<TeacherLinkingEmailStatus>,
162 pub notification_email: Option<NotificationEmailStatus>,
165}
166
167#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
174pub struct CourseCreditRegistrationModuleSummary {
175 pub course_module_id: Uuid,
176 pub course_module_name: Option<String>,
177 pub enabled: bool,
178 pub paused: bool,
179 pub registration_count: i64,
182 pub registered_count: i64,
184 pub in_progress_count: i64,
186 pub waiting_on_student_count: i64,
188 pub failed_count: i64,
189 pub not_registering_count: i64,
191 pub needs_admin_attention_count: i64,
194}
195
196#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
199pub struct CourseCreditRegistrationSummary {
200 pub modules: Vec<CourseCreditRegistrationModuleSummary>,
201 pub unlinked_enrolled_student_count: i64,
204 pub linking_emails_failed_to_send_count: i64,
206}
207
208#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
209pub struct CourseCreditRegistrationsPage {
210 pub data: Vec<CourseCreditRegistration>,
211 pub total_pages: u32,
212}
213
214#[derive(Debug, Deserialize, ToSchema)]
216pub struct CourseCreditRegistrationUserIdsPayload {
217 pub user_ids: Vec<Uuid>,
218 pub course_instance_id: Option<Uuid>,
219}
220
221#[derive(Debug, Deserialize)]
222pub struct GetCourseCreditRegistrationsQuery {
223 page: Option<u32>,
224 limit: Option<u32>,
225 search: Option<SecretString>,
226 state: Option<CreditRegistrationState>,
227 status: Option<Vec<StudentFacingCreditRegistrationStatus>>,
228 course_instance_id: Option<Uuid>,
229}
230
231#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
234pub struct CourseCreditRegistrationEvent {
235 pub id: Uuid,
236 pub created_at: DateTime<Utc>,
237 pub kind: CreditRegistrationEventKind,
238 pub from_state: Option<CreditRegistrationState>,
239 pub to_state: Option<CreditRegistrationState>,
240 pub error_code: Option<CreditRegistrationErrorCode>,
241 pub message: Option<String>,
243 pub actor_user_id: Option<Uuid>,
244}
245
246#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
247pub struct CreditRegistrationDetails {
248 pub course_id: Uuid,
249 pub course_name: String,
250 pub registration: CourseCreditRegistration,
251 pub attempts: Vec<CourseCreditRegistration>,
253 pub events: Vec<CourseCreditRegistrationEvent>,
254 pub not_improved_attainment: Option<NotImprovedAttainment>,
257}
258
259#[derive(Debug, Deserialize, ToSchema)]
260pub struct ResendLinkingEmailPayload {
261 pub user_id: Option<Uuid>,
263 #[schema(value_type = Option<String>)]
264 pub student_number: Option<SecretString>,
265 pub reason: Option<String>,
266}
267
268#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
269pub struct ResendLinkingEmailResult {
270 pub outcome: ResendOutcome,
271 pub linking_email: Option<TeacherLinkingEmailStatus>,
273 pub mails_sent_for_this_course: i64,
274 pub max_mails_per_person_and_course: i64,
275}
276
277#[instrument(skip(pool))]
282#[utoipa::path(
283 get,
284 path = "/courses/{course_id}/module-configs",
285 operation_id = "getCourseCreditRegistrationModuleConfigs",
286 tag = "course-credit-registrations",
287 params(("course_id" = Uuid, Path, description = "Course id")),
288 responses(
289 (status = 200, description = "Every module of the course with its Suotar configuration", body = Vec<CourseModuleCreditRegistrationConfig>)
290 )
291)]
292pub async fn get_course_credit_registration_module_configs(
293 user: AuthUser,
294 pool: web::Data<PgPool>,
295 course_id: web::Path<Uuid>,
296) -> ControllerResult<web::Json<Vec<CourseModuleCreditRegistrationConfig>>> {
297 let mut conn = pool.acquire().await?;
298 let token = authorize(
299 &mut conn,
300 Act::ViewInternalCourseStructure,
301 Some(user.id),
302 Res::Course(*course_id),
303 )
304 .await?;
305
306 let modules =
307 models::course_modules::get_credit_registration_configs_by_course_id(&mut conn, *course_id)
308 .await?;
309 token.authorized_ok(web::Json(modules))
310}
311
312fn stage_of(group: &CourseModuleStateCount) -> StudentFacingCreditRegistrationStatus {
314 StudentFacingCreditRegistrationStatus::of(
315 group.state,
316 PendingPreconditions {
317 completion_eligible: group.completion_eligible,
318 has_verified_student_number: group.has_verified_student_number,
319 course_code_allowed: group.course_code_allowed,
320 },
321 group.enrolment_resolved,
322 )
323}
324
325#[derive(Debug, Deserialize)]
326pub struct CourseCreditRegistrationSummaryQuery {
327 course_instance_id: Option<Uuid>,
328}
329
330#[instrument(skip(pool))]
338#[utoipa::path(
339 get,
340 path = "/courses/{course_id}/summary",
341 operation_id = "getCourseCreditRegistrationSummary",
342 tag = "course-credit-registrations",
343 params(
344 ("course_id" = Uuid, Path, description = "Course id"),
345 ("course_instance_id" = Option<Uuid>, Query, description = "Narrows the per-module counts to one instance")
346 ),
347 responses(
348 (status = 200, description = "The course's credit registration summary", body = CourseCreditRegistrationSummary)
349 )
350)]
351pub async fn get_course_credit_registration_summary(
352 user: AuthUser,
353 pool: web::Data<PgPool>,
354 course_id: web::Path<Uuid>,
355 query: web::Query<CourseCreditRegistrationSummaryQuery>,
356) -> ControllerResult<web::Json<CourseCreditRegistrationSummary>> {
357 let mut conn = pool.acquire().await?;
358 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
359
360 let configs =
361 models::course_modules::get_credit_registration_configs_by_course_id(&mut conn, *course_id)
362 .await?;
363 let on_push_path = configs
364 .iter()
365 .any(|config| config.enable_credit_registration_via_suotar);
366 let module_names: HashMap<Uuid, Option<String>> =
367 models::course_modules::get_by_course_id(&mut conn, *course_id)
368 .await?
369 .into_iter()
370 .map(|module| (module.id, module.name))
371 .collect();
372 let counts = models::credit_registrations::count_by_module_and_state_for_course(
373 &mut conn,
374 *course_id,
375 query.course_instance_id,
376 )
377 .await?;
378
379 use StudentFacingCreditRegistrationStatus as Stage;
380 let modules = configs
381 .into_iter()
382 .map(|config| {
383 let groups: Vec<&CourseModuleStateCount> = counts
384 .iter()
385 .filter(|group| group.course_module_id == config.course_module_id)
386 .collect();
387 let mut by_stage: HashMap<Stage, i64> = HashMap::new();
388 for group in &groups {
389 *by_stage.entry(stage_of(group)).or_default() += group.count;
390 }
391 let in_stage = |stage: Stage| -> i64 { by_stage.get(&stage).copied().unwrap_or(0) };
392 CourseCreditRegistrationModuleSummary {
393 course_module_id: config.course_module_id,
394 course_module_name: module_names
395 .get(&config.course_module_id)
396 .cloned()
397 .unwrap_or_default(),
398 enabled: config.enable_credit_registration_via_suotar,
399 paused: config.credit_registration_paused_at.is_some(),
400 registration_count: groups.iter().map(|group| group.count).sum(),
401 registered_count: in_stage(Stage::Registered),
402 in_progress_count: in_stage(Stage::LookingForEnrolment)
403 + in_stage(Stage::Sending)
404 + in_stage(Stage::WaitingForSisu),
405 waiting_on_student_count: in_stage(Stage::WaitingForCompletion)
406 + in_stage(Stage::NeedsStudentNumber)
407 + in_stage(Stage::NeedsEnrolment),
408 failed_count: in_stage(Stage::Failed),
409 not_registering_count: in_stage(Stage::NotRegistering)
410 + in_stage(Stage::WaitingForCourseSetup),
411 needs_admin_attention_count: groups
412 .iter()
413 .map(|group| group.needs_admin_attention_count)
414 .sum(),
415 }
416 })
417 .collect();
418
419 let unlinked_enrolled_student_count = if on_push_path {
421 verified_student_numbers::count_unlinked_enrolled_students_for_course(&mut conn, *course_id)
422 .await?
423 } else {
424 0
425 };
426 let linking_emails_failed_to_send_count = if on_push_path {
427 count_failed_linking_emails(&mut conn, *course_id).await?
428 } else {
429 0
430 };
431
432 token.authorized_ok(web::Json(CourseCreditRegistrationSummary {
433 modules,
434 unlinked_enrolled_student_count,
435 linking_emails_failed_to_send_count,
436 }))
437}
438
439#[instrument(skip(pool, payload))]
444#[utoipa::path(
445 post,
446 path = "/courses/{course_id}/by-user-ids",
447 operation_id = "getCourseCreditRegistrationsForUsers",
448 tag = "course-credit-registrations",
449 params(("course_id" = Uuid, Path, description = "Course id")),
450 request_body = CourseCreditRegistrationUserIdsPayload,
451 responses(
452 (status = 200, description = "The named students' registrations", body = Vec<CourseCreditRegistration>)
453 )
454)]
455pub async fn get_course_credit_registrations_for_users(
456 user: AuthUser,
457 pool: web::Data<PgPool>,
458 course_id: web::Path<Uuid>,
459 payload: web::Json<CourseCreditRegistrationUserIdsPayload>,
460) -> ControllerResult<web::Json<Vec<CourseCreditRegistration>>> {
461 let mut conn = pool.acquire().await?;
462 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
463
464 if payload.user_ids.len() > MAX_USER_IDS_PER_REQUEST {
465 return Err(controller_err!(
466 BadRequest,
467 format!("Name at most {MAX_USER_IDS_PER_REQUEST} students per request.")
468 ));
469 }
470 let rows = models::credit_registrations::get_teacher_facing_by_course_id(
471 &mut conn,
472 *course_id,
473 &TeacherCreditRegistrationFilters {
474 user_ids: Some(&payload.user_ids),
475 course_instance_id: payload.course_instance_id,
476 ..TeacherCreditRegistrationFilters::default()
477 },
478 MAX_ROWS_PER_REQUEST + 1,
483 0,
484 )
485 .await?;
486 if rows.len() as i64 > MAX_ROWS_PER_REQUEST {
487 return Err(controller_err!(
488 BadRequest,
489 format!(
490 "This request would return more than {MAX_ROWS_PER_REQUEST} registration rows. Name fewer students per request."
491 )
492 ));
493 }
494 let res = build_teacher_registrations(&mut conn, *course_id, rows).await?;
495
496 token.authorized_ok(web::Json(res))
497}
498
499#[instrument(skip(pool))]
504#[utoipa::path(
505 get,
506 path = "/courses/{course_id}/list",
507 operation_id = "getCourseCreditRegistrations",
508 tag = "course-credit-registrations",
509 params(
510 ("course_id" = Uuid, Path, description = "Course id"),
511 ("page" = Option<u32>, Query, description = "Page number, from 1"),
512 ("limit" = Option<u32>, Query, description = "Rows per page"),
513 ("search" = Option<String>, Query, description = "Student name, email or student number"),
514 ("state" = Option<CreditRegistrationState>, Query, description = "Ledger state filter"),
515 ("status" = Option<Vec<StudentFacingCreditRegistrationStatus>>, Query, description = "Student-facing stage filter; repeat the parameter for several"),
516 ("course_instance_id" = Option<Uuid>, Query, description = "Course instance filter")
517 ),
518 responses(
519 (status = 200, description = "A page of the course's registrations", body = CourseCreditRegistrationsPage)
520 )
521)]
522pub async fn get_course_credit_registrations(
523 user: AuthUser,
524 pool: web::Data<PgPool>,
525 course_id: web::Path<Uuid>,
526 query: MultiQuery<GetCourseCreditRegistrationsQuery>,
527) -> ControllerResult<web::Json<CourseCreditRegistrationsPage>> {
528 let mut conn = pool.acquire().await?;
529 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
530
531 let pagination = parse_pagination(query.page, query.limit, 100)?;
532 let search = non_empty(expose_option(&query.search));
533 let filters = TeacherCreditRegistrationFilters {
534 state: query.state,
535 stages: query.status.as_deref().unwrap_or_default(),
536 search,
537 course_instance_id: query.course_instance_id,
538 ..TeacherCreditRegistrationFilters::default()
539 };
540 let total = models::credit_registrations::count_teacher_facing_by_course_id(
541 &mut conn, *course_id, &filters,
542 )
543 .await?;
544 let rows = models::credit_registrations::get_teacher_facing_by_course_id(
545 &mut conn,
546 *course_id,
547 &filters,
548 pagination.limit(),
549 pagination.offset(),
550 )
551 .await?;
552 let data = build_teacher_registrations(&mut conn, *course_id, rows).await?;
553
554 token.authorized_ok(web::Json(CourseCreditRegistrationsPage {
555 data,
556 total_pages: pagination.total_pages(u32::try_from(total).unwrap_or(u32::MAX)),
557 }))
558}
559
560#[instrument(skip(pool))]
568#[utoipa::path(
569 get,
570 path = "/registrations/{credit_registration_id}",
571 operation_id = "getCreditRegistrationDetails",
572 tag = "course-credit-registrations",
573 params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
574 responses(
575 (status = 200, description = "The registration with its timeline", body = CreditRegistrationDetails),
576 (status = 404, description = "No such registration")
577 )
578)]
579pub async fn get_credit_registration_details(
580 user: AuthUser,
581 pool: web::Data<PgPool>,
582 credit_registration_id: web::Path<Uuid>,
583) -> ControllerResult<web::Json<CreditRegistrationDetails>> {
584 let mut conn = pool.acquire().await?;
585 let row =
586 models::credit_registrations::get_teacher_facing_by_id(&mut conn, *credit_registration_id)
587 .await?
588 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
589 let token = authorize_credit_registration_teacher(&mut conn, user.id, row.course_id).await?;
590
591 let course = models::courses::get_course(&mut conn, row.course_id).await?;
592 let registration_id = row.id;
593 let attempt_rows =
594 models::credit_registrations::get_teacher_facing_attempts_for_completion(&mut conn, &row)
595 .await?;
596 let attempts = build_teacher_registrations(&mut conn, row.course_id, attempt_rows).await?;
599 let registration = attempts
600 .iter()
601 .find(|attempt| attempt.id == registration_id)
602 .cloned()
603 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
604 let events = models::credit_registration_events::get_by_registration_id(
605 &mut conn,
606 *credit_registration_id,
607 )
608 .await?
609 .into_iter()
610 .map(|event| CourseCreditRegistrationEvent {
611 id: event.id,
612 created_at: event.created_at,
613 kind: event.kind,
614 from_state: event.from_state,
615 to_state: event.to_state,
616 error_code: event.error_code,
617 message: event.message,
618 actor_user_id: event.actor_user_id,
619 })
620 .collect();
621
622 let not_improved_attainment = models::credit_registration_events::get_not_improved_attainment(
623 &mut conn,
624 *credit_registration_id,
625 )
626 .await?;
627
628 token.authorized_ok(web::Json(CreditRegistrationDetails {
629 course_id: course.id,
630 course_name: course.name,
631 registration,
632 attempts,
633 events,
634 not_improved_attainment,
635 }))
636}
637
638#[instrument(skip(pool, payload, app_conf, suotar_client))]
647#[utoipa::path(
648 post,
649 path = "/courses/{course_id}/resend-linking-email",
650 operation_id = "resendCourseCreditRegistrationLinkingEmail",
651 tag = "course-credit-registrations",
652 params(("course_id" = Uuid, Path, description = "Course id")),
653 request_body = ResendLinkingEmailPayload,
654 responses(
655 (status = 200, description = "What the attempt did", body = ResendLinkingEmailResult),
656 (status = 400, description = "Nothing named, or this teacher has set off too many mails this hour")
657 )
658)]
659pub async fn resend_course_credit_registration_linking_email(
660 user: AuthUser,
661 pool: web::Data<PgPool>,
662 course_id: web::Path<Uuid>,
663 payload: web::Json<ResendLinkingEmailPayload>,
664 app_conf: web::Data<ApplicationConfiguration>,
665 suotar_client: web::Data<SuotarClient>,
666) -> ControllerResult<web::Json<ResendLinkingEmailResult>> {
667 let mut conn = pool.acquire().await?;
668 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
669 ensure_resend_possible(&mut conn, &app_conf, *course_id).await?;
670
671 let recent = models::credit_registration_admin_actions::count_by_actor_since(
672 &mut conn,
673 user.id,
674 CreditRegistrationAdminAction::ResendLinkEmail,
675 Utc::now() - chrono::Duration::hours(1),
676 )
677 .await?;
678 if recent >= MAX_TEACHER_RESENDS_PER_HOUR {
679 return Err(controller_err!(
680 BadRequest,
681 "You have sent too many confirmation emails in the last hour. Try again later."
682 .to_string()
683 ));
684 }
685
686 let student_number = resolve_resend_target(&mut conn, *course_id, &payload).await?;
687 let Some(student_number) = student_number else {
688 return finish_resend(
689 &mut conn,
690 &user,
691 *course_id,
692 &payload,
693 None,
694 ResendOutcome::NoStudentNumberKnown,
695 token,
696 )
697 .await;
698 };
699
700 let ctx = ManualActionContext::new(&pool, &suotar_client, RESEND_CALLER);
701 drop(conn);
704 info!(actor = %user.id, course_id = %*course_id, "Teacher requested a linking mail resend");
705 let attempt = resend_linking_mail_for_target(&ctx, *course_id, &student_number, None).await?;
706 let mut conn = pool.acquire().await?;
707 let outcome = ResendOutcome::from(attempt.outcome);
708 info!(?outcome, "Teacher linking mail resend finished");
709
710 finish_resend(
711 &mut conn,
712 &user,
713 *course_id,
714 &payload,
715 Some(&student_number),
716 outcome,
717 token,
718 )
719 .await
720}
721
722async fn resolve_resend_target(
729 conn: &mut PgConnection,
730 course_id: Uuid,
731 payload: &ResendLinkingEmailPayload,
732) -> Result<Option<SecretString>, ControllerError> {
733 if let Some(student_number) = non_empty(expose_option(&payload.student_number)) {
734 return Ok(Some(SecretString::from(student_number)));
735 }
736 let Some(user_id) = payload.user_id else {
737 return Err(controller_err!(
738 BadRequest,
739 "Name either a user or a student number.".to_string()
740 ));
741 };
742 if !models::credit_registrations::exists_for_user_and_course(conn, user_id, course_id).await? {
743 return Err(controller_err!(
746 BadRequest,
747 "That account has no credit registration on this course.".to_string()
748 ));
749 }
750 Ok(
751 verified_student_numbers::get_latest_including_deleted_by_user_id(conn, user_id)
752 .await?
753 .map(|link| link.student_number.into()),
754 )
755}
756
757async fn finish_resend(
759 conn: &mut PgConnection,
760 user: &AuthUser,
761 course_id: Uuid,
762 payload: &ResendLinkingEmailPayload,
763 student_number: Option<&SecretString>,
764 outcome: ResendOutcome,
765 token: crate::domain::authorization::AuthorizationToken,
766) -> ControllerResult<web::Json<ResendLinkingEmailResult>> {
767 let (mails, mails_sent_for_this_course) = record_resend_and_fetch_mails(
768 conn,
769 course_id,
770 student_number.map(ExposeSecret::expose_secret),
771 user.id,
772 COURSE_TEACHER_ROLE,
773 Some(course_id),
774 payload.reason.clone(),
775 serde_json::json!({
776 "outcome": outcome,
777 "student_number": student_number.map(ExposeSecret::expose_secret),
778 }),
779 )
780 .await?;
781 let linking_email = match mails.first() {
782 Some(mail) => latest_linking_email_status(conn, mail).await?,
783 None => None,
784 };
785
786 token.authorized_ok(web::Json(ResendLinkingEmailResult {
787 outcome,
788 linking_email,
789 mails_sent_for_this_course,
790 max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
791 }))
792}
793
794#[allow(clippy::too_many_arguments)]
799pub(crate) async fn record_resend_and_fetch_mails(
800 conn: &mut PgConnection,
801 course_id: Uuid,
802 student_number: Option<&str>,
803 actor_user_id: Uuid,
804 actor_role: &str,
805 actor_course_id: Option<Uuid>,
806 reason: Option<String>,
807 details: serde_json::Value,
808) -> Result<(Vec<CreditRegistrationAccountLinkingEmail>, i64), ControllerError> {
809 models::credit_registration_admin_actions::record(
810 conn,
811 &NewCreditRegistrationAdminAction {
812 target_id: Some(course_id),
813 actor_course_id,
814 reason,
815 details: Some(details),
816 ..NewCreditRegistrationAdminAction::new(
817 CreditRegistrationAdminAction::ResendLinkEmail,
818 CreditRegistrationAdminActionTarget::Course,
819 actor_user_id,
820 actor_role,
821 )
822 },
823 )
824 .await?;
825
826 let mails = match student_number {
827 Some(number) => {
828 credit_registration_account_linking_emails::get_by_course_id_and_student_number(
829 conn, course_id, number,
830 )
831 .await?
832 }
833 None => Vec::new(),
834 };
835 let mails_sent_for_this_course = mails.len() as i64;
836 Ok((mails, mails_sent_for_this_course))
837}
838
839async fn latest_linking_email_status(
840 conn: &mut PgConnection,
841 mail: &CreditRegistrationAccountLinkingEmail,
842) -> Result<Option<TeacherLinkingEmailStatus>, ControllerError> {
843 let reports =
844 credit_registration_account_linking_emails::get_send_status_reports(conn, &[mail.id])
845 .await?;
846 Ok(reports
847 .get(&mail.id)
848 .map(|report| linking_email_status_of(report, mail)))
849}
850
851fn linking_email_status_of(
852 report: &EmailSendStatusReport,
853 mail: &CreditRegistrationAccountLinkingEmail,
854) -> TeacherLinkingEmailStatus {
855 TeacherLinkingEmailStatus {
856 email_send_status: report.email_send_status,
857 sent_at: report.sent_at,
858 last_attempt_at: report.last_attempt_at,
859 retry_count: report.retry_count,
860 next_retry_at: report.next_retry_at,
861 emailed_to_masked: mask_email(mail.emailed_to.expose_secret()),
862 }
863}
864
865async fn linking_email_statuses(
868 conn: &mut PgConnection,
869 course_id: Uuid,
870 waiting: &[&TeacherCreditRegistration],
871) -> Result<HashMap<Uuid, TeacherLinkingEmailStatus>, ControllerError> {
872 if waiting.is_empty() {
873 return Ok(HashMap::new());
874 }
875 let need_lookup: Vec<Uuid> = waiting
876 .iter()
877 .filter(|row| row.sisu_person_id.is_none())
878 .map(|row| row.user_id)
879 .collect();
880 let latest_links: HashMap<Uuid, String> = if need_lookup.is_empty() {
881 HashMap::new()
882 } else {
883 verified_student_numbers::get_latest_including_deleted_by_user_ids(conn, &need_lookup)
884 .await?
885 .into_iter()
886 .filter_map(|link| {
887 let person_id = link.sisu_person_id?.expose_secret().to_owned();
888 Some((link.user_id, person_id))
889 })
890 .collect()
891 };
892 let per_row: Vec<(Uuid, String)> = waiting
893 .iter()
894 .filter_map(|row| {
895 let person_id = row
896 .sisu_person_id
897 .as_ref()
898 .map(|id| id.expose_secret().to_owned())
899 .or_else(|| latest_links.get(&row.user_id).cloned())?;
900 Some((row.id, person_id))
901 })
902 .collect();
903 if per_row.is_empty() {
904 return Ok(HashMap::new());
905 }
906 let person_ids: Vec<String> = per_row
907 .iter()
908 .map(|(_, person_id)| person_id.clone())
909 .collect();
910 let mails = credit_registration_account_linking_emails::get_latest_by_course_and_persons(
911 conn,
912 course_id,
913 &person_ids,
914 )
915 .await?;
916 let matched: Vec<(Uuid, &CreditRegistrationAccountLinkingEmail)> = per_row
917 .iter()
918 .filter_map(|(row_id, person_id)| Some((*row_id, mails.get(person_id)?)))
919 .collect();
920 if matched.is_empty() {
921 return Ok(HashMap::new());
922 }
923 let mail_ids: Vec<Uuid> = matched.iter().map(|(_, mail)| mail.id).collect();
924 let reports =
925 credit_registration_account_linking_emails::get_send_status_reports(conn, &mail_ids)
926 .await?;
927 Ok(matched
928 .into_iter()
929 .filter_map(|(row_id, mail)| {
930 let report = reports.get(&mail.id)?;
931 Some((row_id, linking_email_status_of(report, mail)))
932 })
933 .collect())
934}
935
936pub(crate) async fn build_teacher_registrations(
942 conn: &mut PgConnection,
943 course_id: Uuid,
944 rows: Vec<TeacherCreditRegistration>,
945) -> Result<Vec<CourseCreditRegistration>, ControllerError> {
946 let waiting: Vec<&TeacherCreditRegistration> = rows
947 .iter()
948 .filter(|row| {
949 StudentFacingCreditRegistrationStatus::of(
950 row.state,
951 row.preconditions(),
952 row.enrolment_resolved,
953 ) == StudentFacingCreditRegistrationStatus::NeedsStudentNumber
954 })
955 .collect();
956 let mut statuses = linking_email_statuses(conn, course_id, &waiting).await?;
957 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
958 let notification_mails = student_notifications::get_for_registrations(conn, &ids).await?;
959 Ok(rows
960 .into_iter()
961 .map(|row| {
962 let linking_email = statuses.remove(&row.id);
963 let resubmission_refusal = row
965 .resubmission_facts()
966 .resubmission_refusal(ResubmissionStrictness::OnlyFailedPermanent);
967 let state = row.state;
968 let base = CourseCreditRegistration::from(row);
969 let notification_email =
970 NotificationEmailStatus::for_state(state, base.id, ¬ification_mails);
971 CourseCreditRegistration {
972 linking_email,
973 notification_email,
974 resubmission_refusal,
975 ..base
976 }
977 })
978 .collect())
979}
980
981impl From<TeacherCreditRegistration> for CourseCreditRegistration {
982 fn from(row: TeacherCreditRegistration) -> Self {
983 let can_request_enrolment_recheck = can_request_enrolment_recheck(
984 row.state,
985 row.enrolment_check_requested_at,
986 row.enrolment_checked_at,
987 );
988 Self {
989 can_request_enrolment_recheck,
990 student_facing_status: StudentFacingCreditRegistrationStatus::of(
991 row.state,
992 row.preconditions(),
993 row.enrolment_resolved,
994 ),
995 superseded: row.superseded_by_id.is_some(),
996 linking_email: None,
997 notification_email: None,
998 resubmission_refusal: None,
999 id: row.id,
1000 user_id: row.user_id,
1001 first_name: row.first_name,
1002 last_name: row.last_name,
1003 email: row.email,
1004 course_id: row.course_id,
1005 course_module_id: row.course_module_id,
1006 course_module_name: row.course_module_name,
1007 course_instance_id: row.course_instance_id,
1008 course_module_completion_id: row.course_module_completion_id,
1009 completion_date: row.completion_date,
1010 state: row.state,
1011 state_entered_at: row.state_entered_at,
1012 error_code: row.error_code,
1013 needs_admin_attention: row.needs_admin_attention,
1014 next_attempt_at: row.next_attempt_at,
1015 registered_at: row.registered_at,
1016 sisu_attainment_id: row.sisu_attainment_id,
1017 grade_id: row.grade_id,
1018 credits: row.credits,
1019 attempt_number: row.attempt_number,
1020 student_number: expose_option(&row.student_number).map(str::to_owned),
1021 student_number_verified_at: row.student_number_verified_at,
1022 student_number_verified_via: row.student_number_verified_via,
1023 enrolment_realisation_name: row.enrolment_realisation_name,
1024 }
1025 }
1026}
1027
1028async fn count_failed_linking_emails(
1030 conn: &mut PgConnection,
1031 course_id: Uuid,
1032) -> Result<i64, ControllerError> {
1033 Ok(
1034 credit_registration_account_linking_emails::count_send_failed_for_course(
1035 conn,
1036 course_id,
1037 Utc::now(),
1038 )
1039 .await?,
1040 )
1041}
1042
1043pub fn _add_routes(cfg: &mut ServiceConfig) {
1044 cfg.route(
1045 "/courses/{course_id}/module-configs",
1046 web::get().to(get_course_credit_registration_module_configs),
1047 )
1048 .route(
1049 "/courses/{course_id}/summary",
1050 web::get().to(get_course_credit_registration_summary),
1051 )
1052 .route(
1053 "/courses/{course_id}/by-user-ids",
1054 web::post().to(get_course_credit_registrations_for_users),
1055 )
1056 .route(
1057 "/courses/{course_id}/list",
1058 web::get().to(get_course_credit_registrations),
1059 )
1060 .route(
1061 "/courses/{course_id}/resend-linking-email",
1062 web::post().to(resend_course_credit_registration_linking_email),
1063 )
1064 .route(
1065 "/registrations/{credit_registration_id}",
1066 web::get().to(get_credit_registration_details),
1067 );
1068 retry::_add_routes(cfg);
1069 enrolment_recheck::_add_routes(cfg);
1070 actions::_add_routes(cfg);
1071 export::_add_routes(cfg);
1072}