Skip to main content

headless_lms_server/controllers/main_frontend/course_credit_registrations/
mod.rs

1/*!
2Handlers for HTTP requests to `/api/v0/main-frontend/course-credit-registrations`.
3
4Teachers see the unmasked student number, but recipient addresses are masked to their domain, the
5study registry's own error text is never returned, and nothing here can override a rate cap.
6
7Every handler with a student in it authorizes on `ViewAndManageCreditRegistrations`, which an
8assistant does not hold: `ViewUserProgressOrDetails` and `Edit` would both let a course's assistants —
9often students on the same programme — list and export every classmate's national study registry
10identity. The one exception is the module configuration, which names no student.
11
12Every mutating handler writes exactly one `credit_registration_admin_actions` row with
13`actor_role = 'course_teacher'`, in the transaction that has the effect.
14*/
15
16mod 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
63/// Every handler here that names a student gates on this; see the module doc for why
64/// `ViewAndManageCreditRegistrations` and not a broader course permission.
65pub(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
80/// A fat-finger guard on top of the per-person caps, which this endpoint cannot relax.
81const MAX_TEACHER_RESENDS_PER_HOUR: i64 = 20;
82
83/// Marks the resend's study registry call in the call log as a manual action, not worker traffic.
84const RESEND_CALLER: &str = "teacher-resend";
85
86/// The by-user-ids lookup is bounded by what the caller names rather than by a page, so the payload
87/// itself has to be bounded. Comfortably above the students tab's page size.
88const MAX_USER_IDS_PER_REQUEST: usize = 500;
89
90/// `MAX_USER_IDS_PER_REQUEST` * a generous per-student attempt count would allow a ~25,000-row join
91/// per request; this is the real ceiling. A course with this many attempts across the named students
92/// needs a narrower request, not a bigger response.
93const 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/// What we can honestly say about a linking mail: our send status and the address's domain.
112#[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    /// The same collapsed stage the student is shown, so both audiences read one classification.
138    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    /// Why a teacher's retry would refuse this row, or `null` if it would put it back on the
149    /// pipeline: what the row's retry control renders from.
150    pub resubmission_refusal: Option<ResubmissionRefusal>,
151    /// Whether the row's "check enrolment again" action is available now; it shares the student's
152    /// button's allowance.
153    pub can_request_enrolment_recheck: bool,
154    /// In full: a masked number cannot be checked against a student card.
155    pub student_number: Option<String>,
156    pub student_number_verified_at: Option<DateTime<Utc>>,
157    /// `admin_manual` means support established the link rather than the student proving it.
158    pub student_number_verified_via: Option<StudentNumberVerificationMethod>,
159    pub enrolment_realisation_name: Option<String>,
160    /// Only where we can join the account to a Sisu person, which needs a link past or present.
161    pub linking_email: Option<TeacherLinkingEmailStatus>,
162    /// The terminal-state mail this row's status has, if one has been queued. Same derivation the
163    /// student sees, so a teacher answering "did they hear from you" cannot be told something else.
164    pub notification_email: Option<NotificationEmailStatus>,
165}
166
167/// One module's live registrations, split so a teacher can add the columns up.
168///
169/// `registered_count`, `in_progress_count`, `waiting_on_student_count`, `failed_count` and
170/// `not_registering_count` partition `registration_count`: every live row falls in exactly one, and
171/// each is the same classification the row's own badge shows. `needs_admin_attention_count` is not
172/// one of them — it cuts across all five — so it is never added to them.
173#[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    /// Live registrations of this module, replaced attempts excluded. Registrations, not
179    /// completions: one per student per module, and a regrade replaces rather than adds.
180    pub registration_count: i64,
181    /// The credit exists in the study registry, whoever put it there.
182    pub registered_count: i64,
183    /// The pipeline is working on it and nobody has to do anything.
184    pub in_progress_count: i64,
185    /// Waiting for the student: their completion, their student number or their enrolment.
186    pub waiting_on_student_count: i64,
187    pub failed_count: i64,
188    /// Blocked, cancelled or held until the course's settings are fixed: nothing is moving.
189    pub not_registering_count: i64,
190    /// Rows the pipeline handed to support. Nothing for a teacher to do; shown so a module's
191    /// failures do not read as unattended.
192    pub needs_admin_attention_count: i64,
193}
194
195/// The teacher-facing credit registration overview of one course. Both student-number totals are
196/// zero unless some module of the course registers credits.
197#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
198pub struct CourseCreditRegistrationSummary {
199    pub modules: Vec<CourseCreditRegistrationModuleSummary>,
200    /// Course-wide, whatever the per-module counts were narrowed to: enrolled students we hold no
201    /// student number for.
202    pub unlinked_enrolled_student_count: i64,
203    /// Of the unlinked enrolled students, the ones whose linking mail we never managed to hand over.
204    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/// Body for the students-tab batch: the users of the current identity-list page.
214#[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/// One event of the item timeline, without the stored request and response bodies: those are the
231/// admin dashboard's, and the study registry's own wording is never rendered.
232#[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    /// Our own wording, written by the pipeline or by whoever acted.
241    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    /// Every attempt for the same completion, newest first, this one included.
251    pub attempts: Vec<CourseCreditRegistration>,
252    pub events: Vec<CourseCreditRegistrationEvent>,
253    /// The grade the registry already held, for a row it declined as no improvement. The
254    /// registration's own grade is what we sent.
255    pub not_improved_attainment: Option<NotImprovedAttainment>,
256}
257
258#[derive(Debug, Deserialize, ToSchema)]
259pub struct ResendLinkingEmailPayload {
260    /// One of the two names the person; `user_id` only resolves for an account that has held a number.
261    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    /// The latest mail for this person and course after the attempt, whatever the outcome.
271    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/**
277GET `/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/module-configs` - The
278course's per-module credit registration configuration.
279*/
280#[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
311/// One count group's stage, the same classification the group's own rows carry.
312fn 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/**
330GET `/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/summary` - Per-module
331counts plus the two reasons a student of this course will not get credits.
332
333Course-wide unless `course_instance_id` narrows the per-module counts to one instance. The two
334student-number totals are course-wide either way: a student holds one number, not one per instance.
335*/
336#[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    // On a course that registers nothing these would report the whole roster as a backlog.
424    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/**
444POST `/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/by-user-ids` - The
445registrations of the named students, for the students tab's current page.
446*/
447#[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        // Every attempt of every named student, not a page: the tab renders one cell per student and
483        // a student's superseded attempts belong in it. Bounded by the cap above rather than by a
484        // page size, which is why the sibling endpoints' `Pagination` does not apply. Queried one row
485        // over the real cap so exceeding it is detectable rather than silently truncated.
486        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/**
504GET `/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/list` - A page of the
505course's registrations, filtered by state and searched by student name, email or student number.
506*/
507#[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/**
565GET `/api/v0/main-frontend/course-credit-registrations/registrations/{credit_registration_id}` - One
566registration with its timeline and the other attempts for the same completion.
567
568Authorized on the row's own course: a course id from the caller would let a teacher of one course pair
569it with a foreign registration id.
570*/
571#[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    // `attempt_rows` already contains `row`, so it is picked out of `attempts` rather than fetched
601    // a second time.
602    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/**
643POST
644`/api/v0/main-frontend/course-credit-registrations/courses/{course_id}/resend-linking-email` - Sets off
645another account-linking mail for one person on this course.
646
647The target has to be on this course's roster in the study registry and hold no link with us. The caps
648of the ordinary claim path apply and nothing here relaxes them.
649*/
650#[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    // Released first: the call below takes connections of its own and can hold the request for the
697    // whole Suotar timeout, so keeping this one would tie up three of the pool per resend.
698    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
715/// The person the body names, as a student number. `None` when the account has never held one.
716///
717/// A `user_id` is only answered for an account with a registration on `course_id`. The caller is
718/// authorized on the course and the study registry roster is only consulted later, so without this one
719/// course's teacher could hand in any account's uuid and read off, from which outcome came back,
720/// whether that account holds a verified student number.
721async 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        // Deliberately the same answer for an account that does not exist: the two must not be
737        // distinguishable.
738        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
750/// Audits the attempt whatever it did, and reports where the person's linking mail now stands.
751/// `attempted` is `None` when no student number was known to try.
752async 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/// Records a `ResendLinkEmail` action against the course and fetches this student's linking mails
796/// for it. Shared by the teacher and admin resend endpoints, which differ only in who they blame it
797/// on, whether they widen the action to the whole course (`actor_course_id`), and what extra detail
798/// goes into `details`.
799#[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
866/// The newest linking mail's send status for each row waiting for a number, by row id. A fixed number
867/// of queries whatever the page holds, because only the listed people are looked up.
868async 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
937/// Enriches the ledger rows with the linking-mail status. A row only gets one when the account holds —
938/// or once held — a link, because the mail is addressed to a Sisu person.
939///
940/// Shared with the csv export so the file and the table cannot disagree about a row's status or
941/// about how much of an address is shown.
942pub(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            // The teacher's own retry strictness, so the row says exactly what that button would do.
965            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, &notification_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
1029/// Linking mails of this course we could not hand over at all.
1030async 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}