1mod actions;
17mod export;
18mod retry;
19
20use headless_lms_models::course_module_suotar_realisations::CourseModuleSuotarRealisation;
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 CreditRegistrationErrorCode, CreditRegistrationState, ResubmissionRefusal,
31 ResubmissionStrictness, TeacherCreditRegistration, TeacherCreditRegistrationFilters,
32};
33use headless_lms_models::email_deliveries::{EmailSendStatus, EmailSendStatusReport};
34use headless_lms_models::library::credit_registration::StudentFacingCreditRegistrationStatus;
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::verified_student_numbers::StudentNumberVerificationMethod;
38use headless_lms_models::{
39 credit_registration_account_linking_emails::{self, CreditRegistrationAccountLinkingEmail},
40 verified_student_numbers,
41};
42use std::collections::HashMap;
43use utoipa::{OpenApi, ToSchema};
44
45use crate::domain::credit_registration_phases::PhaseContext;
46use crate::domain::credit_registration_phases::linking_mail_resend::{
47 ResendOutcome, resend_linking_mail_for_target,
48};
49use crate::prelude::*;
50use headless_lms_base::config::ApplicationConfiguration;
51use headless_lms_utils::services::suotar::SuotarClient;
52
53use super::credit_registrations::{NotificationEmailStatus, mask_email};
54
55pub(crate) async fn authorize_credit_registration_teacher(
58 conn: &mut PgConnection,
59 user_id: Uuid,
60 course_id: Uuid,
61) -> Result<crate::domain::authorization::AuthorizationToken, ControllerError> {
62 authorize(
63 conn,
64 Act::ViewAndManageCreditRegistrations,
65 Some(user_id),
66 Res::Course(course_id),
67 )
68 .await
69 .map_err(Into::into)
70}
71
72const MAX_TEACHER_RESENDS_PER_HOUR: i64 = 20;
74
75const RESEND_CALLER: &str = "teacher-resend";
77
78const MAX_USER_IDS_PER_REQUEST: usize = 500;
81
82const MAX_ROWS_PER_REQUEST: i64 = 2_000;
86
87#[derive(OpenApi)]
88#[openapi(paths(
89 get_course_credit_registration_module_configs,
90 get_course_credit_registration_summary,
91 get_course_credit_registrations_for_users,
92 get_course_credit_registrations,
93 get_credit_registration_details,
94 resend_course_credit_registration_linking_email,
95 retry::retry_credit_registration,
96 retry::retry_failed_credit_registrations_for_course,
97 actions::get_course_credit_registration_actions,
98 export::export_course_credit_registrations
99))]
100pub(crate) struct MainFrontendCourseCreditRegistrationsApiDoc;
101
102#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
104pub struct CourseCreditRegistrationModuleConfigs {
105 pub modules: Vec<CourseModuleCreditRegistrationConfig>,
106 pub realisations: Vec<CourseModuleSuotarRealisation>,
108}
109
110#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
112pub struct TeacherLinkingEmailStatus {
113 pub email_send_status: EmailSendStatus,
114 pub sent_at: Option<DateTime<Utc>>,
115 pub last_attempt_at: Option<DateTime<Utc>>,
116 pub retry_count: i32,
117 pub next_retry_at: Option<DateTime<Utc>>,
118 pub emailed_to_masked: String,
119}
120
121#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
122pub struct CourseCreditRegistration {
123 pub id: Uuid,
124 pub user_id: Uuid,
125 pub first_name: Option<String>,
126 pub last_name: Option<String>,
127 pub email: Option<String>,
128 pub course_id: Uuid,
129 pub course_module_id: Uuid,
130 pub course_module_name: Option<String>,
131 pub course_instance_id: Uuid,
132 pub course_module_completion_id: Uuid,
133 pub completion_date: DateTime<Utc>,
134 pub state: CreditRegistrationState,
135 pub state_entered_at: DateTime<Utc>,
136 pub student_facing_status: StudentFacingCreditRegistrationStatus,
138 pub error_code: Option<CreditRegistrationErrorCode>,
139 pub needs_admin_attention: bool,
140 pub next_attempt_at: DateTime<Utc>,
141 pub registered_at: Option<DateTime<Utc>>,
142 pub sisu_attainment_id: Option<String>,
143 pub grade_id: Option<String>,
144 pub credits: Option<f32>,
145 pub attempt_number: i32,
146 pub superseded: bool,
147 pub resubmission_refusal: Option<ResubmissionRefusal>,
150 pub student_number: Option<String>,
152 pub student_number_verified_at: Option<DateTime<Utc>>,
153 pub student_number_verified_via: Option<StudentNumberVerificationMethod>,
155 pub enrolment_realisation_name: Option<String>,
156 pub linking_email: Option<TeacherLinkingEmailStatus>,
158 pub notification_email: Option<NotificationEmailStatus>,
161}
162
163#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
164pub struct CourseCreditRegistrationModuleSummary {
165 pub course_module_id: Uuid,
166 pub course_module_name: Option<String>,
167 pub enabled: bool,
168 pub paused: bool,
169 pub counts_by_state: Vec<CreditRegistrationStateCount>,
170 pub success_count: i64,
172 pub failed_permanent_count: i64,
174 pub needs_admin_attention_count: i64,
175}
176
177#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
178pub struct CreditRegistrationStateCount {
179 pub state: CreditRegistrationState,
180 pub count: i64,
181}
182
183#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
184pub struct CourseCreditRegistrationSummary {
185 pub modules: Vec<CourseCreditRegistrationModuleSummary>,
186 pub unlinked_enrolled_student_count: i64,
188 pub linking_emails_failed_to_send_count: i64,
190}
191
192#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
193pub struct CourseCreditRegistrationsPage {
194 pub data: Vec<CourseCreditRegistration>,
195 pub total_pages: u32,
196}
197
198#[derive(Debug, Deserialize, ToSchema)]
200pub struct CourseCreditRegistrationUserIdsPayload {
201 pub user_ids: Vec<Uuid>,
202 pub course_instance_id: Option<Uuid>,
203}
204
205#[derive(Debug, Deserialize)]
206pub struct GetCourseCreditRegistrationsQuery {
207 page: Option<u32>,
208 limit: Option<u32>,
209 search: Option<String>,
210 state: Option<CreditRegistrationState>,
211 course_instance_id: Option<Uuid>,
212}
213
214#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
217pub struct CourseCreditRegistrationEvent {
218 pub id: Uuid,
219 pub created_at: DateTime<Utc>,
220 pub kind: CreditRegistrationEventKind,
221 pub from_state: Option<CreditRegistrationState>,
222 pub to_state: Option<CreditRegistrationState>,
223 pub error_code: Option<CreditRegistrationErrorCode>,
224 pub message: Option<String>,
226 pub actor_user_id: Option<Uuid>,
227}
228
229#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
230pub struct CreditRegistrationDetails {
231 pub course_id: Uuid,
232 pub course_name: String,
233 pub registration: CourseCreditRegistration,
234 pub attempts: Vec<CourseCreditRegistration>,
236 pub events: Vec<CourseCreditRegistrationEvent>,
237 pub not_improved_attainment: Option<NotImprovedAttainment>,
240}
241
242#[derive(Debug, Deserialize, ToSchema)]
243pub struct ResendLinkingEmailPayload {
244 pub user_id: Option<Uuid>,
246 pub student_number: Option<String>,
247 pub reason: Option<String>,
248}
249
250#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
251pub struct ResendLinkingEmailResult {
252 pub outcome: ResendOutcome,
253 pub linking_email: Option<TeacherLinkingEmailStatus>,
255 pub mails_sent_for_this_course: i64,
256 pub max_mails_per_person_and_course: i64,
257}
258
259#[instrument(skip(pool))]
264#[utoipa::path(
265 get,
266 path = "/courses/{course_id}/module-configs",
267 operation_id = "getCourseCreditRegistrationModuleConfigs",
268 tag = "course-credit-registrations",
269 params(("course_id" = Uuid, Path, description = "Course id")),
270 responses(
271 (status = 200, description = "The course's per-module configuration", body = CourseCreditRegistrationModuleConfigs)
272 )
273)]
274pub async fn get_course_credit_registration_module_configs(
275 user: AuthUser,
276 pool: web::Data<PgPool>,
277 course_id: web::Path<Uuid>,
278) -> ControllerResult<web::Json<CourseCreditRegistrationModuleConfigs>> {
279 let mut conn = pool.acquire().await?;
280 let token = authorize(
281 &mut conn,
282 Act::ViewInternalCourseStructure,
283 Some(user.id),
284 Res::Course(*course_id),
285 )
286 .await?;
287
288 let modules =
289 models::course_modules::get_credit_registration_configs_by_course_id(&mut conn, *course_id)
290 .await?;
291 let realisations =
292 models::course_module_suotar_realisations::get_by_course_id(&mut conn, *course_id).await?;
293
294 token.authorized_ok(web::Json(CourseCreditRegistrationModuleConfigs {
295 modules,
296 realisations,
297 }))
298}
299
300#[instrument(skip(pool))]
305#[utoipa::path(
306 get,
307 path = "/courses/{course_id}/summary",
308 operation_id = "getCourseCreditRegistrationSummary",
309 tag = "course-credit-registrations",
310 params(("course_id" = Uuid, Path, description = "Course id")),
311 responses(
312 (status = 200, description = "The course's credit registration summary", body = CourseCreditRegistrationSummary)
313 )
314)]
315pub async fn get_course_credit_registration_summary(
316 user: AuthUser,
317 pool: web::Data<PgPool>,
318 course_id: web::Path<Uuid>,
319) -> ControllerResult<web::Json<CourseCreditRegistrationSummary>> {
320 let mut conn = pool.acquire().await?;
321 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
322
323 let configs =
324 models::course_modules::get_credit_registration_configs_by_course_id(&mut conn, *course_id)
325 .await?;
326 let module_names: HashMap<Uuid, Option<String>> =
327 models::course_modules::get_by_course_id(&mut conn, *course_id)
328 .await?
329 .into_iter()
330 .map(|module| (module.id, module.name))
331 .collect();
332 let counts =
333 models::credit_registrations::count_by_module_and_state_for_course(&mut conn, *course_id)
334 .await?;
335 let mut attention: HashMap<Uuid, i64> = HashMap::new();
336 for (module_id, _, _, needs_admin_attention_count) in &counts {
337 *attention.entry(*module_id).or_insert(0) += needs_admin_attention_count;
338 }
339
340 let modules = configs
341 .into_iter()
342 .map(|config| {
343 let counts_by_state: Vec<CreditRegistrationStateCount> = counts
344 .iter()
345 .filter(|(module_id, _, _, _)| *module_id == config.course_module_id)
346 .map(|(_, state, count, _)| CreditRegistrationStateCount {
347 state: *state,
348 count: *count,
349 })
350 .collect();
351 CourseCreditRegistrationModuleSummary {
352 course_module_id: config.course_module_id,
353 course_module_name: module_names
354 .get(&config.course_module_id)
355 .cloned()
356 .unwrap_or_default(),
357 enabled: config.enable_credit_registration_via_suotar,
358 paused: config.credit_registration_paused_at.is_some(),
359 success_count: counts_by_state
360 .iter()
361 .filter(|row| row.state.is_success())
362 .map(|row| row.count)
363 .sum(),
364 failed_permanent_count: counts_by_state
365 .iter()
366 .filter(|row| row.state == CreditRegistrationState::FailedPermanent)
367 .map(|row| row.count)
368 .sum(),
369 needs_admin_attention_count: attention
370 .get(&config.course_module_id)
371 .copied()
372 .unwrap_or(0),
373 counts_by_state,
374 }
375 })
376 .collect();
377
378 let unlinked_enrolled_student_count =
379 verified_student_numbers::count_unlinked_enrolled_students_for_course(
380 &mut conn, *course_id,
381 )
382 .await?;
383 let linking_emails_failed_to_send_count =
384 count_failed_linking_emails(&mut conn, *course_id).await?;
385
386 token.authorized_ok(web::Json(CourseCreditRegistrationSummary {
387 modules,
388 unlinked_enrolled_student_count,
389 linking_emails_failed_to_send_count,
390 }))
391}
392
393#[instrument(skip(pool, payload))]
398#[utoipa::path(
399 post,
400 path = "/courses/{course_id}/by-user-ids",
401 operation_id = "getCourseCreditRegistrationsForUsers",
402 tag = "course-credit-registrations",
403 params(("course_id" = Uuid, Path, description = "Course id")),
404 request_body = CourseCreditRegistrationUserIdsPayload,
405 responses(
406 (status = 200, description = "The named students' registrations", body = Vec<CourseCreditRegistration>)
407 )
408)]
409pub async fn get_course_credit_registrations_for_users(
410 user: AuthUser,
411 pool: web::Data<PgPool>,
412 course_id: web::Path<Uuid>,
413 payload: web::Json<CourseCreditRegistrationUserIdsPayload>,
414) -> ControllerResult<web::Json<Vec<CourseCreditRegistration>>> {
415 let mut conn = pool.acquire().await?;
416 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
417
418 if payload.user_ids.len() > MAX_USER_IDS_PER_REQUEST {
419 return Err(controller_err!(
420 BadRequest,
421 format!("Name at most {MAX_USER_IDS_PER_REQUEST} students per request.")
422 ));
423 }
424 let rows = models::credit_registrations::get_teacher_facing_by_course_id(
425 &mut conn,
426 *course_id,
427 &TeacherCreditRegistrationFilters {
428 user_ids: Some(&payload.user_ids),
429 course_instance_id: payload.course_instance_id,
430 ..TeacherCreditRegistrationFilters::default()
431 },
432 MAX_ROWS_PER_REQUEST + 1,
437 0,
438 )
439 .await?;
440 if rows.len() as i64 > MAX_ROWS_PER_REQUEST {
441 return Err(controller_err!(
442 BadRequest,
443 format!(
444 "This request would return more than {MAX_ROWS_PER_REQUEST} registration rows. Name fewer students per request."
445 )
446 ));
447 }
448 let res = build_teacher_registrations(&mut conn, *course_id, rows).await?;
449
450 token.authorized_ok(web::Json(res))
451}
452
453#[instrument(skip(pool))]
458#[utoipa::path(
459 get,
460 path = "/courses/{course_id}/list",
461 operation_id = "getCourseCreditRegistrations",
462 tag = "course-credit-registrations",
463 params(
464 ("course_id" = Uuid, Path, description = "Course id"),
465 ("page" = Option<u32>, Query, description = "Page number, from 1"),
466 ("limit" = Option<u32>, Query, description = "Rows per page"),
467 ("search" = Option<String>, Query, description = "Student name, email or student number"),
468 ("state" = Option<CreditRegistrationState>, Query, description = "Ledger state filter"),
469 ("course_instance_id" = Option<Uuid>, Query, description = "Course instance filter")
470 ),
471 responses(
472 (status = 200, description = "A page of the course's registrations", body = CourseCreditRegistrationsPage)
473 )
474)]
475pub async fn get_course_credit_registrations(
476 user: AuthUser,
477 pool: web::Data<PgPool>,
478 course_id: web::Path<Uuid>,
479 query: web::Query<GetCourseCreditRegistrationsQuery>,
480) -> ControllerResult<web::Json<CourseCreditRegistrationsPage>> {
481 let mut conn = pool.acquire().await?;
482 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
483
484 let pagination = parse_pagination(query.page, query.limit, 100)?;
485 let search = non_empty(query.search.as_deref());
486 let filters = TeacherCreditRegistrationFilters {
487 state: query.state,
488 search,
489 course_instance_id: query.course_instance_id,
490 ..TeacherCreditRegistrationFilters::default()
491 };
492 let total = models::credit_registrations::count_teacher_facing_by_course_id(
493 &mut conn, *course_id, &filters,
494 )
495 .await?;
496 let rows = models::credit_registrations::get_teacher_facing_by_course_id(
497 &mut conn,
498 *course_id,
499 &filters,
500 pagination.limit(),
501 pagination.offset(),
502 )
503 .await?;
504 let data = build_teacher_registrations(&mut conn, *course_id, rows).await?;
505
506 token.authorized_ok(web::Json(CourseCreditRegistrationsPage {
507 data,
508 total_pages: pagination.total_pages(u32::try_from(total).unwrap_or(u32::MAX)),
509 }))
510}
511
512#[instrument(skip(pool))]
520#[utoipa::path(
521 get,
522 path = "/registrations/{credit_registration_id}",
523 operation_id = "getCreditRegistrationDetails",
524 tag = "course-credit-registrations",
525 params(("credit_registration_id" = Uuid, Path, description = "Credit registration id")),
526 responses(
527 (status = 200, description = "The registration with its timeline", body = CreditRegistrationDetails),
528 (status = 404, description = "No such registration")
529 )
530)]
531pub async fn get_credit_registration_details(
532 user: AuthUser,
533 pool: web::Data<PgPool>,
534 credit_registration_id: web::Path<Uuid>,
535) -> ControllerResult<web::Json<CreditRegistrationDetails>> {
536 let mut conn = pool.acquire().await?;
537 let row =
538 models::credit_registrations::get_teacher_facing_by_id(&mut conn, *credit_registration_id)
539 .await?
540 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
541 let token = authorize_credit_registration_teacher(&mut conn, user.id, row.course_id).await?;
542
543 let course = models::courses::get_course(&mut conn, row.course_id).await?;
544 let registration_id = row.id;
545 let attempt_rows =
546 models::credit_registrations::get_teacher_facing_attempts_for_completion(&mut conn, &row)
547 .await?;
548 let attempts = build_teacher_registrations(&mut conn, row.course_id, attempt_rows).await?;
551 let registration = attempts
552 .iter()
553 .find(|attempt| attempt.id == registration_id)
554 .cloned()
555 .ok_or_else(|| controller_err!(NotFound, "Not found.".to_string()))?;
556 let events = models::credit_registration_events::get_by_registration_id(
557 &mut conn,
558 *credit_registration_id,
559 )
560 .await?
561 .into_iter()
562 .map(|event| CourseCreditRegistrationEvent {
563 id: event.id,
564 created_at: event.created_at,
565 kind: event.kind,
566 from_state: event.from_state,
567 to_state: event.to_state,
568 error_code: event.error_code,
569 message: event.message,
570 actor_user_id: event.actor_user_id,
571 })
572 .collect();
573
574 let not_improved_attainment = models::credit_registration_events::get_not_improved_attainment(
575 &mut conn,
576 *credit_registration_id,
577 )
578 .await?;
579
580 token.authorized_ok(web::Json(CreditRegistrationDetails {
581 course_id: course.id,
582 course_name: course.name,
583 registration,
584 attempts,
585 events,
586 not_improved_attainment,
587 }))
588}
589
590#[instrument(skip(pool, payload, app_conf, suotar_client))]
599#[utoipa::path(
600 post,
601 path = "/courses/{course_id}/resend-linking-email",
602 operation_id = "resendCourseCreditRegistrationLinkingEmail",
603 tag = "course-credit-registrations",
604 params(("course_id" = Uuid, Path, description = "Course id")),
605 request_body = ResendLinkingEmailPayload,
606 responses(
607 (status = 200, description = "What the attempt did", body = ResendLinkingEmailResult),
608 (status = 400, description = "Nothing named, or this teacher has set off too many mails this hour")
609 )
610)]
611pub async fn resend_course_credit_registration_linking_email(
612 user: AuthUser,
613 pool: web::Data<PgPool>,
614 course_id: web::Path<Uuid>,
615 payload: web::Json<ResendLinkingEmailPayload>,
616 app_conf: web::Data<ApplicationConfiguration>,
617 suotar_client: web::Data<SuotarClient>,
618) -> ControllerResult<web::Json<ResendLinkingEmailResult>> {
619 let mut conn = pool.acquire().await?;
620 let token = authorize_credit_registration_teacher(&mut conn, user.id, *course_id).await?;
621
622 let enabled_module_ids =
623 models::course_modules::get_credit_registration_enabled_ids_for_course(
624 &mut conn, *course_id,
625 )
626 .await?;
627 if enabled_module_ids.is_empty() {
628 return Err(controller_err!(
629 BadRequest,
630 "This course has no credit registration module configured.".to_string()
631 ));
632 }
633
634 let recent = models::credit_registration_admin_actions::count_by_actor_since(
635 &mut conn,
636 user.id,
637 CreditRegistrationAdminAction::ResendLinkEmail,
638 Utc::now() - chrono::Duration::hours(1),
639 )
640 .await?;
641 if recent >= MAX_TEACHER_RESENDS_PER_HOUR {
642 return Err(controller_err!(
643 BadRequest,
644 "You have set off too many linking emails in the last hour.".to_string()
645 ));
646 }
647
648 let student_number = resolve_resend_target(&mut conn, *course_id, &payload).await?;
649 let Some(student_number) = student_number else {
650 return finish_resend(
651 &mut conn,
652 &user,
653 *course_id,
654 &payload,
655 None,
656 ResendOutcome::NoStudentNumberKnown,
657 token,
658 )
659 .await;
660 };
661
662 let ctx = PhaseContext::from_app(&pool, &suotar_client, &app_conf, RESEND_CALLER);
663 drop(conn);
666 let attempt = resend_linking_mail_for_target(
667 &ctx,
668 *course_id,
669 &student_number,
670 Box::pin(async { Ok(0) }),
671 )
672 .await?;
673 let mut conn = pool.acquire().await?;
674 let outcome = ResendOutcome::from(attempt.decision);
675
676 finish_resend(
677 &mut conn,
678 &user,
679 *course_id,
680 &payload,
681 Some(&student_number),
682 outcome,
683 token,
684 )
685 .await
686}
687
688async fn resolve_resend_target(
695 conn: &mut PgConnection,
696 course_id: Uuid,
697 payload: &ResendLinkingEmailPayload,
698) -> Result<Option<String>, ControllerError> {
699 if let Some(student_number) = non_empty(payload.student_number.as_deref()) {
700 return Ok(Some(student_number.to_string()));
701 }
702 let Some(user_id) = payload.user_id else {
703 return Err(controller_err!(
704 BadRequest,
705 "Name either a user or a student number.".to_string()
706 ));
707 };
708 if !models::credit_registrations::exists_for_user_and_course(conn, user_id, course_id).await? {
709 return Err(controller_err!(
712 BadRequest,
713 "That account has no credit registration on this course.".to_string()
714 ));
715 }
716 Ok(
717 verified_student_numbers::get_latest_including_deleted_by_user_id(conn, user_id)
718 .await?
719 .map(|link| link.student_number),
720 )
721}
722
723async fn finish_resend(
725 conn: &mut PgConnection,
726 user: &AuthUser,
727 course_id: Uuid,
728 payload: &ResendLinkingEmailPayload,
729 student_number: Option<&str>,
730 outcome: ResendOutcome,
731 token: crate::domain::authorization::AuthorizationToken,
732) -> ControllerResult<web::Json<ResendLinkingEmailResult>> {
733 let (mails, mails_sent_for_this_course) = record_resend_and_fetch_mails(
734 conn,
735 course_id,
736 student_number,
737 user.id,
738 COURSE_TEACHER_ROLE,
739 Some(course_id),
740 payload.reason.clone(),
741 serde_json::json!({
742 "outcome": outcome,
743 "student_number": student_number,
744 }),
745 )
746 .await?;
747 let linking_email = match mails.first() {
748 Some(mail) => latest_linking_email_status(conn, mail).await?,
749 None => None,
750 };
751
752 token.authorized_ok(web::Json(ResendLinkingEmailResult {
753 outcome,
754 linking_email,
755 mails_sent_for_this_course,
756 max_mails_per_person_and_course: MAX_LINKING_MAILS_PER_PERSON_AND_COURSE,
757 }))
758}
759
760#[allow(clippy::too_many_arguments)]
765pub(crate) async fn record_resend_and_fetch_mails(
766 conn: &mut PgConnection,
767 course_id: Uuid,
768 student_number: Option<&str>,
769 actor_user_id: Uuid,
770 actor_role: &str,
771 actor_course_id: Option<Uuid>,
772 reason: Option<String>,
773 details: serde_json::Value,
774) -> Result<(Vec<CreditRegistrationAccountLinkingEmail>, i64), ControllerError> {
775 models::credit_registration_admin_actions::record(
776 conn,
777 &NewCreditRegistrationAdminAction {
778 target_id: Some(course_id),
779 actor_course_id,
780 reason,
781 details: Some(details),
782 ..NewCreditRegistrationAdminAction::new(
783 CreditRegistrationAdminAction::ResendLinkEmail,
784 CreditRegistrationAdminActionTarget::Course,
785 actor_user_id,
786 actor_role,
787 )
788 },
789 )
790 .await?;
791
792 let mails = match student_number {
793 Some(number) => {
794 credit_registration_account_linking_emails::get_by_course_id_and_student_number(
795 conn, course_id, number,
796 )
797 .await?
798 }
799 None => Vec::new(),
800 };
801 let mails_sent_for_this_course = mails.len() as i64;
802 Ok((mails, mails_sent_for_this_course))
803}
804
805async fn latest_linking_email_status(
806 conn: &mut PgConnection,
807 mail: &CreditRegistrationAccountLinkingEmail,
808) -> Result<Option<TeacherLinkingEmailStatus>, ControllerError> {
809 let reports =
810 credit_registration_account_linking_emails::get_send_status_reports(conn, &[mail.id])
811 .await?;
812 Ok(reports
813 .get(&mail.id)
814 .map(|report| linking_email_status_of(report, mail)))
815}
816
817fn linking_email_status_of(
818 report: &EmailSendStatusReport,
819 mail: &CreditRegistrationAccountLinkingEmail,
820) -> TeacherLinkingEmailStatus {
821 TeacherLinkingEmailStatus {
822 email_send_status: report.email_send_status,
823 sent_at: report.sent_at,
824 last_attempt_at: report.last_attempt_at,
825 retry_count: report.retry_count,
826 next_retry_at: report.next_retry_at,
827 emailed_to_masked: mask_email(&mail.emailed_to),
828 }
829}
830
831async fn linking_email_statuses(
834 conn: &mut PgConnection,
835 course_id: Uuid,
836 waiting: &[&TeacherCreditRegistration],
837) -> Result<HashMap<Uuid, TeacherLinkingEmailStatus>, ControllerError> {
838 if waiting.is_empty() {
839 return Ok(HashMap::new());
840 }
841 let need_lookup: Vec<Uuid> = waiting
842 .iter()
843 .filter(|row| row.sisu_person_id.is_none())
844 .map(|row| row.user_id)
845 .collect();
846 let latest_links: HashMap<Uuid, String> = if need_lookup.is_empty() {
847 HashMap::new()
848 } else {
849 verified_student_numbers::get_latest_including_deleted_by_user_ids(conn, &need_lookup)
850 .await?
851 .into_iter()
852 .map(|link| (link.user_id, link.sisu_person_id))
853 .collect()
854 };
855 let per_row: Vec<(Uuid, String)> = waiting
856 .iter()
857 .filter_map(|row| {
858 let person_id = row
859 .sisu_person_id
860 .clone()
861 .or_else(|| latest_links.get(&row.user_id).cloned())?;
862 Some((row.id, person_id))
863 })
864 .collect();
865 if per_row.is_empty() {
866 return Ok(HashMap::new());
867 }
868 let person_ids: Vec<String> = per_row
869 .iter()
870 .map(|(_, person_id)| person_id.clone())
871 .collect();
872 let mails = credit_registration_account_linking_emails::get_latest_by_course_and_persons(
873 conn,
874 course_id,
875 &person_ids,
876 )
877 .await?;
878 let matched: Vec<(Uuid, &CreditRegistrationAccountLinkingEmail)> = per_row
879 .iter()
880 .filter_map(|(row_id, person_id)| Some((*row_id, mails.get(person_id)?)))
881 .collect();
882 if matched.is_empty() {
883 return Ok(HashMap::new());
884 }
885 let mail_ids: Vec<Uuid> = matched.iter().map(|(_, mail)| mail.id).collect();
886 let reports =
887 credit_registration_account_linking_emails::get_send_status_reports(conn, &mail_ids)
888 .await?;
889 Ok(matched
890 .into_iter()
891 .filter_map(|(row_id, mail)| {
892 let report = reports.get(&mail.id)?;
893 Some((row_id, linking_email_status_of(report, mail)))
894 })
895 .collect())
896}
897
898pub(crate) async fn build_teacher_registrations(
904 conn: &mut PgConnection,
905 course_id: Uuid,
906 rows: Vec<TeacherCreditRegistration>,
907) -> Result<Vec<CourseCreditRegistration>, ControllerError> {
908 let waiting: Vec<&TeacherCreditRegistration> = rows
909 .iter()
910 .filter(|row| {
911 StudentFacingCreditRegistrationStatus::of(row.state, row.preconditions())
912 == StudentFacingCreditRegistrationStatus::NeedsStudentNumber
913 })
914 .collect();
915 let mut statuses = linking_email_statuses(conn, course_id, &waiting).await?;
916 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
917 let notification_mails = student_notifications::get_for_registrations(conn, &ids).await?;
918 Ok(rows
919 .into_iter()
920 .map(|row| {
921 let linking_email = statuses.remove(&row.id);
922 let resubmission_refusal = row.state.resubmission_refusal(
924 row.superseded_by_id.is_some(),
925 ResubmissionStrictness::OnlyFailedPermanent,
926 );
927 let state = row.state;
928 let base = CourseCreditRegistration::from(row);
929 let notification_email =
930 NotificationEmailStatus::for_state(state, base.id, ¬ification_mails);
931 CourseCreditRegistration {
932 linking_email,
933 notification_email,
934 resubmission_refusal,
935 ..base
936 }
937 })
938 .collect())
939}
940
941impl From<TeacherCreditRegistration> for CourseCreditRegistration {
942 fn from(row: TeacherCreditRegistration) -> Self {
943 Self {
944 student_facing_status: StudentFacingCreditRegistrationStatus::of(
945 row.state,
946 row.preconditions(),
947 ),
948 superseded: row.superseded_by_id.is_some(),
949 linking_email: None,
950 notification_email: None,
951 resubmission_refusal: None,
952 id: row.id,
953 user_id: row.user_id,
954 first_name: row.first_name,
955 last_name: row.last_name,
956 email: row.email,
957 course_id: row.course_id,
958 course_module_id: row.course_module_id,
959 course_module_name: row.course_module_name,
960 course_instance_id: row.course_instance_id,
961 course_module_completion_id: row.course_module_completion_id,
962 completion_date: row.completion_date,
963 state: row.state,
964 state_entered_at: row.state_entered_at,
965 error_code: row.error_code,
966 needs_admin_attention: row.needs_admin_attention,
967 next_attempt_at: row.next_attempt_at,
968 registered_at: row.registered_at,
969 sisu_attainment_id: row.sisu_attainment_id,
970 grade_id: row.grade_id,
971 credits: row.credits,
972 attempt_number: row.attempt_number,
973 student_number: row.student_number,
974 student_number_verified_at: row.student_number_verified_at,
975 student_number_verified_via: row.student_number_verified_via,
976 enrolment_realisation_name: row.enrolment_realisation_name,
977 }
978 }
979}
980
981async fn count_failed_linking_emails(
983 conn: &mut PgConnection,
984 course_id: Uuid,
985) -> Result<i64, ControllerError> {
986 Ok(
987 credit_registration_account_linking_emails::count_send_failed_for_course(
988 conn,
989 course_id,
990 Utc::now(),
991 )
992 .await?,
993 )
994}
995
996pub fn _add_routes(cfg: &mut ServiceConfig) {
997 cfg.route(
998 "/courses/{course_id}/module-configs",
999 web::get().to(get_course_credit_registration_module_configs),
1000 )
1001 .route(
1002 "/courses/{course_id}/summary",
1003 web::get().to(get_course_credit_registration_summary),
1004 )
1005 .route(
1006 "/courses/{course_id}/by-user-ids",
1007 web::post().to(get_course_credit_registrations_for_users),
1008 )
1009 .route(
1010 "/courses/{course_id}/list",
1011 web::get().to(get_course_credit_registrations),
1012 )
1013 .route(
1014 "/courses/{course_id}/resend-linking-email",
1015 web::post().to(resend_course_credit_registration_linking_email),
1016 )
1017 .route(
1018 "/registrations/{credit_registration_id}",
1019 web::get().to(get_credit_registration_details),
1020 );
1021 retry::_add_routes(cfg);
1022 actions::_add_routes(cfg);
1023 export::_add_routes(cfg);
1024}