Skip to main content

headless_lms_server/domain/csv_export/
credit_registrations_export.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use bytes::Bytes;
4use headless_lms_models::credit_registrations::TeacherCreditRegistrationFilters;
5use serde::Serialize;
6use sqlx::PgConnection;
7use std::io::Write;
8use tokio::sync::mpsc::UnboundedSender;
9use uuid::Uuid;
10
11use crate::controllers::main_frontend::course_credit_registrations::build_teacher_registrations;
12use crate::domain::csv_export::CsvWriter;
13use crate::prelude::*;
14
15use super::{
16    super::authorization::{AuthorizationToken, AuthorizedResponse},
17    CSVExportAdapter, CsvExportDataLoader,
18};
19
20/// Read in pages so a course with thousands of registrations is never held in memory whole.
21const PAGE_SIZE: i64 = 500;
22
23pub struct CreditRegistrationsExportOperation {
24    pub course_id: Uuid,
25}
26
27#[async_trait]
28impl CsvExportDataLoader for CreditRegistrationsExportOperation {
29    async fn load_data(
30        &self,
31        sender: UnboundedSender<Result<AuthorizedResponse<Bytes>, ControllerError>>,
32        conn: &mut PgConnection,
33        token: AuthorizationToken,
34    ) -> anyhow::Result<CSVExportAdapter> {
35        export_credit_registrations(
36            &mut *conn,
37            self.course_id,
38            CSVExportAdapter {
39                sender,
40                authorization_token: token,
41            },
42        )
43        .await
44    }
45}
46
47/// Writes a course's credit registrations as csv into the writer.
48///
49/// Student numbers go out in full, because the use is comparing one against a student card. The
50/// study registry's own address for the student is reduced to its domain, which is what makes
51/// "check your university mail, not your gmail" sayable without handing over the address itself.
52pub async fn export_credit_registrations<W>(
53    conn: &mut PgConnection,
54    course_id: Uuid,
55    writer: W,
56) -> Result<W>
57where
58    W: Write + Send + 'static,
59{
60    let headers = IntoIterator::into_iter([
61        "user_id".to_string(),
62        "first_name".to_string(),
63        "last_name".to_string(),
64        "email".to_string(),
65        "course_module".to_string(),
66        "completion_date".to_string(),
67        "state".to_string(),
68        "student_facing_status".to_string(),
69        "error_code".to_string(),
70        "needs_admin_attention".to_string(),
71        "attempt_number".to_string(),
72        "superseded".to_string(),
73        "student_number".to_string(),
74        "student_number_verified_at".to_string(),
75        "student_number_verified_via".to_string(),
76        "enrolment_realisation".to_string(),
77        "grade_id".to_string(),
78        "credits".to_string(),
79        "registered_at".to_string(),
80        "sisu_attainment_id".to_string(),
81        "linking_email_status".to_string(),
82        "linking_email_sent_at".to_string(),
83        "linking_email_recipient".to_string(),
84    ]);
85    let writer = CsvWriter::new_with_initialized_headers(writer, headers).await?;
86
87    let mut offset = 0;
88    loop {
89        let rows = headless_lms_models::credit_registrations::get_teacher_facing_by_course_id(
90            conn,
91            course_id,
92            &TeacherCreditRegistrationFilters::default(),
93            PAGE_SIZE,
94            offset,
95        )
96        .await?;
97        let page_len = rows.len() as i64;
98        // The same read model the teacher's table renders, so the file and the screen cannot disagree
99        // about a student's status or about how much of an address is shown.
100        for row in build_teacher_registrations(conn, course_id, rows).await? {
101            writer.write_record(vec![
102                row.user_id.to_string(),
103                row.first_name.unwrap_or_default(),
104                row.last_name.unwrap_or_default(),
105                row.email.unwrap_or_default(),
106                row.course_module_name.unwrap_or_default(),
107                row.completion_date.to_rfc3339(),
108                wire_value(&row.state),
109                wire_value(&row.student_facing_status),
110                wire_value(&row.error_code),
111                row.needs_admin_attention.to_string(),
112                row.attempt_number.to_string(),
113                row.superseded.to_string(),
114                row.student_number.unwrap_or_default(),
115                optional_time(row.student_number_verified_at),
116                wire_value(&row.student_number_verified_via),
117                row.enrolment_realisation_name.unwrap_or_default(),
118                row.grade_id.unwrap_or_default(),
119                row.credits.map(|c| c.to_string()).unwrap_or_default(),
120                optional_time(row.registered_at),
121                row.sisu_attainment_id.unwrap_or_default(),
122                row.linking_email
123                    .as_ref()
124                    .map(|mail| wire_value(&mail.email_send_status))
125                    .unwrap_or_default(),
126                row.linking_email
127                    .as_ref()
128                    .and_then(|mail| mail.sent_at)
129                    .map(|sent_at| sent_at.to_rfc3339())
130                    .unwrap_or_default(),
131                row.linking_email
132                    .map(|mail| mail.emailed_to_masked)
133                    .unwrap_or_default(),
134            ]);
135        }
136        if page_len < PAGE_SIZE {
137            break;
138        }
139        offset += PAGE_SIZE;
140    }
141
142    let writer = writer.finish().await?;
143    Ok(writer)
144}
145
146/// A snake_case enum as the wire spells it, so the file and the API name a state the same way.
147/// `None` becomes an empty cell.
148fn wire_value(value: &impl Serialize) -> String {
149    serde_json::to_value(value)
150        .ok()
151        .and_then(|value| value.as_str().map(str::to_string))
152        .unwrap_or_default()
153}
154
155fn optional_time(value: Option<DateTime<Utc>>) -> String {
156    value.map(|time| time.to_rfc3339()).unwrap_or_default()
157}