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, ResendAttempt, 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 paused: bool,
178 pub registration_count: i64,
181 pub registered_count: i64,
183 pub in_progress_count: i64,
185 pub waiting_on_student_count: i64,
187 pub failed_count: i64,
188 pub not_registering_count: i64,
190 pub needs_admin_attention_count: i64,
193}
194
195#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
198pub struct CourseCreditRegistrationSummary {
199 pub modules: Vec<CourseCreditRegistrationModuleSummary>,
200 pub unlinked_enrolled_student_count: i64,
203 pub linking_emails_failed_to_send_count: i64,
205}
206
207#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
208pub struct CourseCreditRegistrationsPage {
209 pub data: Vec<CourseCreditRegistration>,
210 pub total_pages: u32,
211}
212
213#[derive(Debug, Deserialize, ToSchema)]
215pub struct CourseCreditRegistrationUserIdsPayload {
216 pub user_ids: Vec<Uuid>,
217 pub course_instance_id: Option<Uuid>,
218}
219
220#[derive(Debug, Deserialize)]
221pub struct GetCourseCreditRegistrationsQuery {
222 page: Option<u32>,
223 limit: Option<u32>,
224 search: Option<SecretString>,
225 state: Option<CreditRegistrationState>,
226 status: Option<Vec<StudentFacingCreditRegistrationStatus>>,
227 course_instance_id: Option<Uuid>,
228}
229
230#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
233pub struct CourseCreditRegistrationEvent {
234 pub id: Uuid,
235 pub created_at: DateTime<Utc>,
236 pub kind: CreditRegistrationEventKind,
237 pub from_state: Option<CreditRegistrationState>,
238 pub to_state: Option<CreditRegistrationState>,
239 pub error_code: Option<CreditRegistrationErrorCode>,
240 pub message: Option<String>,
242 pub actor_user_id: Option<Uuid>,
243}
244
245#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
246pub struct CreditRegistrationDetails {
247 pub course_id: Uuid,
248 pub course_name: String,
249 pub registration: CourseCreditRegistration,
250 pub attempts: Vec<CourseCreditRegistration>,
252 pub events: Vec<CourseCreditRegistrationEvent>,
253 pub not_improved_attainment: Option<NotImprovedAttainment>,
256}
257
258#[derive(Debug, Deserialize, ToSchema)]
259pub struct ResendLinkingEmailPayload {
260 pub user_id: Option<Uuid>,
262 #[schema(value_type = Option<String>)]
263 pub student_number: Option<SecretString>,
264 pub reason: Option<String>,
265}
266
267#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
268pub struct ResendLinkingEmailResult {
269 pub outcome: ResendOutcome,
270 pub linking_email: Option<TeacherLinkingEmailStatus>,
272 pub mails_sent_for_this_course: i64,
273 pub max_mails_per_person_and_course: i64,
274}
275
276#[instrument(skip(pool))]
281#[utoipa::path(
282 get,
283 path = "/courses/{course_id}/module-configs",
284 operation_id = "getCourseCreditRegistrationModuleConfigs",
285 tag = "course-credit-registrations",
286 params(("course_id" = Uuid, Path, description = "Course id")),
287 responses(
288 (status = 200, description = "Every module of the course with its Suotar configuration", body = Vec<CourseModuleCreditRegistrationConfig>)
289 )
290)]
291pub async fn get_course_credit_registration_module_configs(
292 user: AuthUser,
293 pool: web::Data<PgPool>,
294 course_id: web::Path<Uuid>,
295) -> ControllerResult<web::Json<Vec<CourseModuleCreditRegistrationConfig>>> {
296 let mut conn = pool.acquire().await?;
297 let token = authorize(
298 &mut conn,
299 Act::ViewInternalCourseStructure,
300 Some(user.id),
301 Res::Course(*course_id),
302 )
303 .await?;
304
305 let modules =
306 models::course_modules::get_credit_registration_configs_by_course_id(&mut conn, *course_id)
307 .await?;
308 token.authorized_ok(web::Json(modules))
309}
310
311fn stage_of(group: &CourseModuleStateCount) -> StudentFacingCreditRegistrationStatus {
313 StudentFacingCreditRegistrationStatus::of(
314 group.state,
315 PendingPreconditions {
316 completion_eligible: group.completion_eligible,
317 has_verified_student_number: group.has_verified_student_number,
318 course_code_allowed: group.course_code_allowed,
319 },
320 group.enrolment_resolved,
321 )
322}
323
324#[derive(Debug, Deserialize)]
325pub struct CourseCreditRegistrationSummaryQuery {
326 course_instance_id: Option<Uuid>,
327}
328
329#[instrument(skip(pool))]
337#[utoipa::path(
338 get,
339 path = "/courses/{course_id}/summary",
340 operation_id = "getCourseCreditRegistrationSummary",
341 tag = "course-credit-registrations",
342 params(
343 ("course_id" = Uuid, Path, description = "Course id"),
344 ("course_instance_id" = Option<Uuid>, Query, description = "Narrows the per-module counts to one instance")
345 ),
346 responses(
347 (status = 200, description = "The course's credit registration summary", body = CourseCreditRegistrationSummary)
348 )
349)]
350pub async fn get_course_credit_registration_summary(
351 user: AuthUser,
352 pool: web::Data<PgPool>,
353 course_id: web::Path<Uuid>,
354 query: web::Query<CourseCreditRegistrationSummaryQuery>,
355) -> ControllerResult<web::Json<CourseCreditRegistrationSummary>> {
356 let mut conn = pool.acquire().await?;
357 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
358
359 let configs: Vec<_> =
360 models::course_modules::get_credit_registration_configs_by_course_id(&mut conn, *course_id)
361 .await?
362 .into_iter()
363 .filter(|config| {
364 models::course_modules::registers_credits(
365 config.uh_course_code.as_deref(),
366 config.completions_can_be_registered_to_sisu,
367 )
368 })
369 .collect();
370 let on_push_path = !configs.is_empty();
371 let module_names: HashMap<Uuid, Option<String>> =
372 models::course_modules::get_by_course_id(&mut conn, *course_id)
373 .await?
374 .into_iter()
375 .map(|module| (module.id, module.name))
376 .collect();
377 let counts = models::credit_registrations::count_by_module_and_state_for_course(
378 &mut conn,
379 *course_id,
380 query.course_instance_id,
381 )
382 .await?;
383
384 use StudentFacingCreditRegistrationStatus as Stage;
385 let modules = configs
386 .into_iter()
387 .map(|config| {
388 let groups: Vec<&CourseModuleStateCount> = counts
389 .iter()
390 .filter(|group| group.course_module_id == config.course_module_id)
391 .collect();
392 let mut by_stage: HashMap<Stage, i64> = HashMap::new();
393 for group in &groups {
394 *by_stage.entry(stage_of(group)).or_default() += group.count;
395 }
396 let in_stage = |stage: Stage| -> i64 { by_stage.get(&stage).copied().unwrap_or(0) };
397 CourseCreditRegistrationModuleSummary {
398 course_module_id: config.course_module_id,
399 course_module_name: module_names
400 .get(&config.course_module_id)
401 .cloned()
402 .unwrap_or_default(),
403 paused: config.credit_registration_paused_at.is_some(),
404 registration_count: groups.iter().map(|group| group.count).sum(),
405 registered_count: in_stage(Stage::Registered),
406 in_progress_count: in_stage(Stage::LookingForEnrolment)
407 + in_stage(Stage::Sending)
408 + in_stage(Stage::WaitingForSisu),
409 waiting_on_student_count: in_stage(Stage::WaitingForCompletion)
410 + in_stage(Stage::NeedsStudentNumber)
411 + in_stage(Stage::NeedsEnrolment),
412 failed_count: in_stage(Stage::Failed),
413 not_registering_count: in_stage(Stage::NotRegistering)
414 + in_stage(Stage::WaitingForCourseSetup),
415 needs_admin_attention_count: groups
416 .iter()
417 .map(|group| group.needs_admin_attention_count)
418 .sum(),
419 }
420 })
421 .collect();
422
423 let unlinked_enrolled_student_count = if on_push_path {
425 verified_student_numbers::count_unlinked_enrolled_students_for_course(&mut conn, *course_id)
426 .await?
427 } else {
428 0
429 };
430 let linking_emails_failed_to_send_count = if on_push_path {
431 count_failed_linking_emails(&mut conn, *course_id).await?
432 } else {
433 0
434 };
435
436 token.authorized_ok(web::Json(CourseCreditRegistrationSummary {
437 modules,
438 unlinked_enrolled_student_count,
439 linking_emails_failed_to_send_count,
440 }))
441}
442
443#[instrument(skip(pool, payload))]
448#[utoipa::path(
449 post,
450 path = "/courses/{course_id}/by-user-ids",
451 operation_id = "getCourseCreditRegistrationsForUsers",
452 tag = "course-credit-registrations",
453 params(("course_id" = Uuid, Path, description = "Course id")),
454 request_body = CourseCreditRegistrationUserIdsPayload,
455 responses(
456 (status = 200, description = "The named students' registrations", body = Vec<CourseCreditRegistration>)
457 )
458)]
459pub async fn get_course_credit_registrations_for_users(
460 user: AuthUser,
461 pool: web::Data<PgPool>,
462 course_id: web::Path<Uuid>,
463 payload: web::Json<CourseCreditRegistrationUserIdsPayload>,
464) -> ControllerResult<web::Json<Vec<CourseCreditRegistration>>> {
465 let mut conn = pool.acquire().await?;
466 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
467
468 if payload.user_ids.len() > MAX_USER_IDS_PER_REQUEST {
469 return Err(controller_err!(
470 BadRequest,
471 format!("Name at most {MAX_USER_IDS_PER_REQUEST} students per request.")
472 ));
473 }
474 let rows = models::credit_registrations::get_teacher_facing_by_course_id(
475 &mut conn,
476 *course_id,
477 &TeacherCreditRegistrationFilters {
478 user_ids: Some(&payload.user_ids),
479 course_instance_id: payload.course_instance_id,
480 ..TeacherCreditRegistrationFilters::default()
481 },
482 MAX_ROWS_PER_REQUEST + 1,
487 0,
488 )
489 .await?;
490 if rows.len() as i64 > MAX_ROWS_PER_REQUEST {
491 return Err(controller_err!(
492 BadRequest,
493 format!(
494 "This request would return more than {MAX_ROWS_PER_REQUEST} registration rows. Name fewer students per request."
495 )
496 ));
497 }
498 let res = build_teacher_registrations(&mut conn, *course_id, rows).await?;
499
500 token.authorized_ok(web::Json(res))
501}
502
503#[instrument(skip(pool))]
508#[utoipa::path(
509 get,
510 path = "/courses/{course_id}/list",
511 operation_id = "getCourseCreditRegistrations",
512 tag = "course-credit-registrations",
513 params(
514 ("course_id" = Uuid, Path, description = "Course id"),
515 ("page" = Option<u32>, Query, description = "Page number, from 1"),
516 ("limit" = Option<u32>, Query, description = "Rows per page"),
517 ("search" = Option<String>, Query, description = "Student name, email or student number"),
518 ("state" = Option<CreditRegistrationState>, Query, description = "Ledger state filter"),
519 ("status" = Option<Vec<StudentFacingCreditRegistrationStatus>>, Query, description = "Student-facing stage filter; repeat the parameter for several"),
520 ("course_instance_id" = Option<Uuid>, Query, description = "Course instance filter")
521 ),
522 responses(
523 (status = 200, description = "A page of the course's registrations", body = CourseCreditRegistrationsPage)
524 )
525)]
526pub async fn get_course_credit_registrations(
527 user: AuthUser,
528 pool: web::Data<PgPool>,
529 course_id: web::Path<Uuid>,
530 query: MultiQuery<GetCourseCreditRegistrationsQuery>,
531) -> ControllerResult<web::Json<CourseCreditRegistrationsPage>> {
532 let mut conn = pool.acquire().await?;
533 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
534
535 let pagination = parse_pagination(query.page, query.limit, 100)?;
536 let search = non_empty(expose_option(&query.search));
537 let filters = TeacherCreditRegistrationFilters {
538 state: query.state,
539 stages: query.status.as_deref().unwrap_or_default(),
540 search,
541 course_instance_id: query.course_instance_id,
542 ..TeacherCreditRegistrationFilters::default()
543 };
544 let total = models::credit_registrations::count_teacher_facing_by_course_id(
545 &mut conn, *course_id, &filters,
546 )
547 .await?;
548 let rows = models::credit_registrations::get_teacher_facing_by_course_id(
549 &mut conn,
550 *course_id,
551 &filters,
552 pagination.limit(),
553 pagination.offset(),
554 )
555 .await?;
556 let data = build_teacher_registrations(&mut conn, *course_id, rows).await?;
557
558 token.authorized_ok(web::Json(CourseCreditRegistrationsPage {
559 data,
560 total_pages: pagination.total_pages(u32::try_from(total).unwrap_or(u32::MAX)),
561 }))
562}
563
564#[instrument(skip(pool))]
572#[utoipa::path(
573 get,
574 path = "/registrations/{credit_registration_id}",
575 operation_id = "getCreditRegistrationDetails",
576 tag = "course-credit-registrations",
577 params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
578 responses(
579 (status = 200, description = "The registration with its timeline", body = CreditRegistrationDetails),
580 (status = 404, description = "No such registration")
581 )
582)]
583pub async fn get_credit_registration_details(
584 user: AuthUser,
585 pool: web::Data<PgPool>,
586 credit_registration_id: web::Path<Uuid>,
587) -> ControllerResult<web::Json<CreditRegistrationDetails>> {
588 let mut conn = pool.acquire().await?;
589 let row =
590 models::credit_registrations::get_teacher_facing_by_id(&mut conn, *credit_registration_id)
591 .await?
592 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
593 let token = authorize_credit_registration_teacher(&mut conn, user.id, row.course_id).await?;
594
595 let course = models::courses::get_course(&mut conn, row.course_id).await?;
596 let registration_id = row.id;
597 let attempt_rows =
598 models::credit_registrations::get_teacher_facing_attempts_for_completion(&mut conn, &row)
599 .await?;
600 let attempts = build_teacher_registrations(&mut conn, row.course_id, attempt_rows).await?;
603 let registration = attempts
604 .iter()
605 .find(|attempt| attempt.id == registration_id)
606 .cloned()
607 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
608 let events = models::credit_registration_events::get_by_registration_id(
609 &mut conn,
610 *credit_registration_id,
611 )
612 .await?
613 .into_iter()
614 .map(|event| CourseCreditRegistrationEvent {
615 id: event.id,
616 created_at: event.created_at,
617 kind: event.kind,
618 from_state: event.from_state,
619 to_state: event.to_state,
620 error_code: event.error_code,
621 message: event.message,
622 actor_user_id: event.actor_user_id,
623 })
624 .collect();
625
626 let not_improved_attainment = models::credit_registration_events::get_not_improved_attainment(
627 &mut conn,
628 *credit_registration_id,
629 )
630 .await?;
631
632 token.authorized_ok(web::Json(CreditRegistrationDetails {
633 course_id: course.id,
634 course_name: course.name,
635 registration,
636 attempts,
637 events,
638 not_improved_attainment,
639 }))
640}
641
642#[instrument(skip(pool, payload, app_conf, suotar_client))]
651#[utoipa::path(
652 post,
653 path = "/courses/{course_id}/resend-linking-email",
654 operation_id = "resendCourseCreditRegistrationLinkingEmail",
655 tag = "course-credit-registrations",
656 params(("course_id" = Uuid, Path, description = "Course id")),
657 request_body = ResendLinkingEmailPayload,
658 responses(
659 (status = 200, description = "What the attempt did", body = ResendLinkingEmailResult),
660 (status = 400, description = "Nothing named, or this teacher has set off too many mails this hour")
661 )
662)]
663pub async fn resend_course_credit_registration_linking_email(
664 user: AuthUser,
665 pool: web::Data<PgPool>,
666 course_id: web::Path<Uuid>,
667 payload: web::Json<ResendLinkingEmailPayload>,
668 app_conf: web::Data<ApplicationConfiguration>,
669 suotar_client: web::Data<SuotarClient>,
670) -> ControllerResult<web::Json<ResendLinkingEmailResult>> {
671 let mut conn = pool.acquire().await?;
672 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
673 ensure_resend_possible(&mut conn, &app_conf, *course_id).await?;
674
675 let recent = models::credit_registration_admin_actions::count_by_actor_since(
676 &mut conn,
677 user.id,
678 CreditRegistrationAdminAction::ResendLinkEmail,
679 Utc::now() - chrono::Duration::hours(1),
680 )
681 .await?;
682 if recent >= MAX_TEACHER_RESENDS_PER_HOUR {
683 return Err(controller_err!(
684 BadRequest,
685 "You have sent too many confirmation emails in the last hour. Try again later."
686 .to_string()
687 ));
688 }
689
690 let student_number = resolve_resend_target(&mut conn, *course_id, &payload).await?;
691 let Some(student_number) = student_number else {
692 return finish_resend(&mut conn, &user, *course_id, &payload, None, token).await;
693 };
694
695 let ctx = ManualActionContext::new(&pool, &suotar_client, RESEND_CALLER);
696 drop(conn);
699 info!(actor = %user.id, course_id = %*course_id, "Teacher requested a linking mail resend");
700 let attempt = resend_linking_mail_for_target(&ctx, *course_id, &student_number, None).await?;
701 let mut conn = pool.acquire().await?;
702 info!(outcome = ?attempt.outcome, "Teacher linking mail resend finished");
703
704 finish_resend(
705 &mut conn,
706 &user,
707 *course_id,
708 &payload,
709 Some((&student_number, &attempt)),
710 token,
711 )
712 .await
713}
714
715async fn resolve_resend_target(
722 conn: &mut PgConnection,
723 course_id: Uuid,
724 payload: &ResendLinkingEmailPayload,
725) -> Result<Option<SecretString>, ControllerError> {
726 if let Some(student_number) = non_empty(expose_option(&payload.student_number)) {
727 return Ok(Some(SecretString::from(student_number)));
728 }
729 let Some(user_id) = payload.user_id else {
730 return Err(controller_err!(
731 BadRequest,
732 "Name either a user or a student number.".to_string()
733 ));
734 };
735 if !models::credit_registrations::exists_for_user_and_course(conn, user_id, course_id).await? {
736 return Err(controller_err!(
739 BadRequest,
740 "That account has no credit registration on this course.".to_string()
741 ));
742 }
743 Ok(
744 verified_student_numbers::get_latest_including_deleted_by_user_id(conn, user_id)
745 .await?
746 .map(|link| link.student_number.into()),
747 )
748}
749
750async fn finish_resend(
753 conn: &mut PgConnection,
754 user: &AuthUser,
755 course_id: Uuid,
756 payload: &ResendLinkingEmailPayload,
757 attempted: Option<(&SecretString, &ResendAttempt)>,
758 token: crate::domain::authorization::AuthorizationToken,
759) -> ControllerResult<web::Json<ResendLinkingEmailResult>> {
760 let student_number = attempted.map(|(number, _)| number.expose_secret());
761 let outcome = attempted.map_or(ResendOutcome::NoStudentNumberKnown, |(_, attempt)| {
762 ResendOutcome::from(attempt.outcome)
763 });
764 let emailed_to = attempted
765 .and_then(|(_, attempt)| attempt.emailed_to.as_ref())
766 .map(ExposeSecret::expose_secret);
767 let (mails, mails_sent_for_this_course) = record_resend_and_fetch_mails(
768 conn,
769 course_id,
770 student_number,
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,
778 "emailed_to": emailed_to,
779 }),
780 )
781 .await?;
782 let linking_email = match mails.first() {
783 Some(mail) => latest_linking_email_status(conn, mail).await?,
784 None => None,
785 };
786
787 token.authorized_ok(web::Json(ResendLinkingEmailResult {
788 outcome,
789 linking_email,
790 mails_sent_for_this_course,
791 max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
792 }))
793}
794
795#[allow(clippy::too_many_arguments)]
800pub(crate) async fn record_resend_and_fetch_mails(
801 conn: &mut PgConnection,
802 course_id: Uuid,
803 student_number: Option<&str>,
804 actor_user_id: Uuid,
805 actor_role: &str,
806 actor_course_id: Option<Uuid>,
807 reason: Option<String>,
808 details: serde_json::Value,
809) -> Result<(Vec<CreditRegistrationAccountLinkingEmail>, i64), ControllerError> {
810 models::credit_registration_admin_actions::record(
811 conn,
812 &NewCreditRegistrationAdminAction {
813 target_id: Some(course_id),
814 actor_course_id,
815 reason,
816 details: Some(details),
817 ..NewCreditRegistrationAdminAction::new(
818 CreditRegistrationAdminAction::ResendLinkEmail,
819 CreditRegistrationAdminActionTarget::Course,
820 actor_user_id,
821 actor_role,
822 )
823 },
824 )
825 .await?;
826
827 let mails = match student_number {
828 Some(number) => {
829 credit_registration_account_linking_emails::get_by_course_id_and_student_number(
830 conn, course_id, number,
831 )
832 .await?
833 }
834 None => Vec::new(),
835 };
836 let mails_sent_for_this_course = mails.len() as i64;
837 Ok((mails, mails_sent_for_this_course))
838}
839
840async fn latest_linking_email_status(
841 conn: &mut PgConnection,
842 mail: &CreditRegistrationAccountLinkingEmail,
843) -> Result<Option<TeacherLinkingEmailStatus>, ControllerError> {
844 let reports =
845 credit_registration_account_linking_emails::get_send_status_reports(conn, &[mail.id])
846 .await?;
847 Ok(reports
848 .get(&mail.id)
849 .map(|report| linking_email_status_of(report, mail)))
850}
851
852fn linking_email_status_of(
853 report: &EmailSendStatusReport,
854 mail: &CreditRegistrationAccountLinkingEmail,
855) -> TeacherLinkingEmailStatus {
856 TeacherLinkingEmailStatus {
857 email_send_status: report.email_send_status,
858 sent_at: report.sent_at,
859 last_attempt_at: report.last_attempt_at,
860 retry_count: report.retry_count,
861 next_retry_at: report.next_retry_at,
862 emailed_to_masked: mask_email(mail.emailed_to.expose_secret()),
863 }
864}
865
866async fn linking_email_statuses(
869 conn: &mut PgConnection,
870 course_id: Uuid,
871 waiting: &[&TeacherCreditRegistration],
872) -> Result<HashMap<Uuid, TeacherLinkingEmailStatus>, ControllerError> {
873 if waiting.is_empty() {
874 return Ok(HashMap::new());
875 }
876 let need_lookup: Vec<Uuid> = waiting
877 .iter()
878 .filter(|row| row.sisu_person_id.is_none())
879 .map(|row| row.user_id)
880 .collect();
881 let latest_links: HashMap<Uuid, String> = if need_lookup.is_empty() {
882 HashMap::new()
883 } else {
884 verified_student_numbers::get_latest_including_deleted_by_user_ids(conn, &need_lookup)
885 .await?
886 .into_iter()
887 .filter_map(|link| {
888 let person_id = link.sisu_person_id?.expose_secret().to_owned();
889 Some((link.user_id, person_id))
890 })
891 .collect()
892 };
893 let per_row: Vec<(Uuid, String)> = waiting
894 .iter()
895 .filter_map(|row| {
896 let person_id = row
897 .sisu_person_id
898 .as_ref()
899 .map(|id| id.expose_secret().to_owned())
900 .or_else(|| latest_links.get(&row.user_id).cloned())?;
901 Some((row.id, person_id))
902 })
903 .collect();
904 if per_row.is_empty() {
905 return Ok(HashMap::new());
906 }
907 let person_ids: Vec<String> = per_row
908 .iter()
909 .map(|(_, person_id)| person_id.clone())
910 .collect();
911 let mails = credit_registration_account_linking_emails::get_latest_by_course_and_persons(
912 conn,
913 course_id,
914 &person_ids,
915 )
916 .await?;
917 let matched: Vec<(Uuid, &CreditRegistrationAccountLinkingEmail)> = per_row
918 .iter()
919 .filter_map(|(row_id, person_id)| Some((*row_id, mails.get(person_id)?)))
920 .collect();
921 if matched.is_empty() {
922 return Ok(HashMap::new());
923 }
924 let mail_ids: Vec<Uuid> = matched.iter().map(|(_, mail)| mail.id).collect();
925 let reports =
926 credit_registration_account_linking_emails::get_send_status_reports(conn, &mail_ids)
927 .await?;
928 Ok(matched
929 .into_iter()
930 .filter_map(|(row_id, mail)| {
931 let report = reports.get(&mail.id)?;
932 Some((row_id, linking_email_status_of(report, mail)))
933 })
934 .collect())
935}
936
937pub(crate) async fn build_teacher_registrations(
943 conn: &mut PgConnection,
944 course_id: Uuid,
945 rows: Vec<TeacherCreditRegistration>,
946) -> Result<Vec<CourseCreditRegistration>, ControllerError> {
947 let waiting: Vec<&TeacherCreditRegistration> = rows
948 .iter()
949 .filter(|row| {
950 StudentFacingCreditRegistrationStatus::of(
951 row.state,
952 row.preconditions(),
953 row.enrolment_resolved,
954 ) == StudentFacingCreditRegistrationStatus::NeedsStudentNumber
955 })
956 .collect();
957 let mut statuses = linking_email_statuses(conn, course_id, &waiting).await?;
958 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
959 let notification_mails = student_notifications::get_for_registrations(conn, &ids).await?;
960 Ok(rows
961 .into_iter()
962 .map(|row| {
963 let linking_email = statuses.remove(&row.id);
964 let resubmission_refusal = row
966 .resubmission_facts()
967 .resubmission_refusal(ResubmissionStrictness::OnlyFailedPermanent);
968 let state = row.state;
969 let base = CourseCreditRegistration::from(row);
970 let notification_email =
971 NotificationEmailStatus::for_state(state, base.id, ¬ification_mails);
972 CourseCreditRegistration {
973 linking_email,
974 notification_email,
975 resubmission_refusal,
976 ..base
977 }
978 })
979 .collect())
980}
981
982impl From<TeacherCreditRegistration> for CourseCreditRegistration {
983 fn from(row: TeacherCreditRegistration) -> Self {
984 let can_request_enrolment_recheck = can_request_enrolment_recheck(
985 row.state,
986 row.enrolment_check_requested_at,
987 row.enrolment_checked_at,
988 );
989 Self {
990 can_request_enrolment_recheck,
991 student_facing_status: StudentFacingCreditRegistrationStatus::of(
992 row.state,
993 row.preconditions(),
994 row.enrolment_resolved,
995 ),
996 superseded: row.superseded_by_id.is_some(),
997 linking_email: None,
998 notification_email: None,
999 resubmission_refusal: None,
1000 id: row.id,
1001 user_id: row.user_id,
1002 first_name: row.first_name,
1003 last_name: row.last_name,
1004 email: row.email,
1005 course_id: row.course_id,
1006 course_module_id: row.course_module_id,
1007 course_module_name: row.course_module_name,
1008 course_instance_id: row.course_instance_id,
1009 course_module_completion_id: row.course_module_completion_id,
1010 completion_date: row.completion_date,
1011 state: row.state,
1012 state_entered_at: row.state_entered_at,
1013 error_code: row.error_code,
1014 needs_admin_attention: row.needs_admin_attention,
1015 next_attempt_at: row.next_attempt_at,
1016 registered_at: row.registered_at,
1017 sisu_attainment_id: row.sisu_attainment_id,
1018 grade_id: row.grade_id,
1019 credits: row.credits,
1020 attempt_number: row.attempt_number,
1021 student_number: expose_option(&row.student_number).map(str::to_owned),
1022 student_number_verified_at: row.student_number_verified_at,
1023 student_number_verified_via: row.student_number_verified_via,
1024 enrolment_realisation_name: row.enrolment_realisation_name,
1025 }
1026 }
1027}
1028
1029async fn count_failed_linking_emails(
1031 conn: &mut PgConnection,
1032 course_id: Uuid,
1033) -> Result<i64, ControllerError> {
1034 Ok(
1035 credit_registration_account_linking_emails::count_send_failed_for_course(
1036 conn,
1037 course_id,
1038 Utc::now(),
1039 )
1040 .await?,
1041 )
1042}
1043
1044pub fn _add_routes(cfg: &mut ServiceConfig) {
1045 cfg.route(
1046 "/courses/{course_id}/module-configs",
1047 web::get().to(get_course_credit_registration_module_configs),
1048 )
1049 .route(
1050 "/courses/{course_id}/summary",
1051 web::get().to(get_course_credit_registration_summary),
1052 )
1053 .route(
1054 "/courses/{course_id}/by-user-ids",
1055 web::post().to(get_course_credit_registrations_for_users),
1056 )
1057 .route(
1058 "/courses/{course_id}/list",
1059 web::get().to(get_course_credit_registrations),
1060 )
1061 .route(
1062 "/courses/{course_id}/resend-linking-email",
1063 web::post().to(resend_course_credit_registration_linking_email),
1064 )
1065 .route(
1066 "/registrations/{credit_registration_id}",
1067 web::get().to(get_credit_registration_details),
1068 );
1069 retry::_add_routes(cfg);
1070 enrolment_recheck::_add_routes(cfg);
1071 actions::_add_routes(cfg);
1072 export::_add_routes(cfg);
1073}