headless_lms_server/domain/csv_export/
course_instance_export.rs

1use anyhow::Result;
2use bytes::Bytes;
3use headless_lms_models::course_instances;
4
5use async_trait::async_trait;
6use itertools::Itertools;
7use models::library::progressing;
8
9use crate::domain::csv_export::CsvWriter;
10
11use sqlx::PgConnection;
12use std::io::Write;
13use tokio::sync::mpsc::UnboundedSender;
14
15use uuid::Uuid;
16
17use crate::prelude::*;
18
19use super::{
20    super::authorization::{AuthorizationToken, AuthorizedResponse},
21    CSVExportAdapter, CsvExportDataLoader, course_module_completion_info_to_grade_string,
22};
23
24pub struct CompletionsExportOperation {
25    pub course_instance_id: Uuid,
26}
27
28#[async_trait]
29impl CsvExportDataLoader for CompletionsExportOperation {
30    async fn load_data(
31        &self,
32        sender: UnboundedSender<Result<AuthorizedResponse<Bytes>, ControllerError>>,
33        conn: &mut PgConnection,
34        token: AuthorizationToken,
35    ) -> anyhow::Result<CSVExportAdapter> {
36        export_completions(
37            &mut *conn,
38            self.course_instance_id,
39            CSVExportAdapter {
40                sender,
41                authorization_token: token,
42            },
43        )
44        .await
45    }
46}
47
48/// Writes the completions as csv into the writer
49pub async fn export_completions<W>(
50    conn: &mut PgConnection,
51    course_instance_id: Uuid,
52    writer: W,
53) -> Result<W>
54where
55    W: Write + Send + 'static,
56{
57    // fetch summary
58    let course_instance = course_instances::get_course_instance(conn, course_instance_id).await?;
59    let summary =
60        progressing::get_course_instance_completion_summary(conn, &course_instance).await?;
61
62    // sort modules
63    let mut modules = summary.course_modules;
64    modules.sort_by_key(|m| m.order_number);
65
66    // prepare headers
67    let mut headers = vec![
68        "user_id".to_string(),
69        "first_name".to_string(),
70        "last_name".to_string(),
71        "email".to_string(),
72    ];
73    for module in &modules {
74        let module_name = module.name.as_deref().unwrap_or("default_module");
75        headers.push(format!("{module_name}_grade"));
76        headers.push(format!("{module_name}_registered"));
77        headers.push(format!("{module_name}_completion_date"));
78    }
79
80    // write rows
81    let writer = CsvWriter::new_with_initialized_headers(writer, headers).await?;
82    for user in summary.users_with_course_module_completions {
83        let mut has_completed_some_module = false;
84
85        let mut csv_row = vec![
86            user.user_id.to_string(),
87            user.first_name.unwrap_or_default(),
88            user.last_name.unwrap_or_default(),
89            user.email,
90        ];
91        for module in &modules {
92            let user_completion = user
93                .completed_modules
94                .iter()
95                // sort by created at, latest timestamp first
96                .sorted_by(|a, b| b.created_at.cmp(&a.created_at))
97                .find(|cm| cm.course_module_id == module.id);
98            if user_completion.is_some() {
99                has_completed_some_module = true;
100            }
101            let grade = course_module_completion_info_to_grade_string(user_completion);
102            csv_row.push(grade);
103            let registered = user_completion
104                .map(|cm| cm.registered.to_string())
105                .unwrap_or_default();
106            csv_row.push(registered);
107            csv_row.push(
108                user_completion
109                    .map(|uc| uc.completion_date.to_rfc3339())
110                    .unwrap_or_default(),
111            )
112        }
113        // To avoid confusion with some people potentially not understanding that '-' means not completed,
114        // we'll skip the users that don't have any completions from any modules. The confusion is less likely in cases where there are more than one module, and only in those cases the teachers would see the '-' entries in this file.
115        if has_completed_some_module {
116            writer.write_record(csv_row);
117        }
118    }
119    let writer = writer.finish().await?;
120    Ok(writer)
121}
122
123pub struct CourseInstancesExportOperation {
124    pub course_id: Uuid,
125}
126
127#[async_trait]
128impl CsvExportDataLoader for CourseInstancesExportOperation {
129    async fn load_data(
130        &self,
131        sender: UnboundedSender<Result<AuthorizedResponse<Bytes>, ControllerError>>,
132        conn: &mut PgConnection,
133        token: AuthorizationToken,
134    ) -> anyhow::Result<CSVExportAdapter> {
135        export_course_instances(
136            &mut *conn,
137            self.course_id,
138            CSVExportAdapter {
139                sender,
140                authorization_token: token,
141            },
142        )
143        .await
144    }
145}
146
147/// Writes the course instances as csv into the writer
148pub async fn export_course_instances<W>(
149    conn: &mut PgConnection,
150    course_id: Uuid,
151    writer: W,
152) -> Result<W>
153where
154    W: Write + Send + 'static,
155{
156    let course_instances =
157        course_instances::get_course_instances_for_course(conn, course_id).await?;
158
159    let headers = IntoIterator::into_iter([
160        "id".to_string(),
161        "created_at".to_string(),
162        "updated_at".to_string(),
163        "name".to_string(),
164    ]);
165    let writer = CsvWriter::new_with_initialized_headers(writer, headers).await?;
166
167    for next in course_instances.into_iter() {
168        let csv_row = vec![
169            next.id.to_string(),
170            next.created_at.to_rfc3339(),
171            next.updated_at.to_rfc3339(),
172            next.name.unwrap_or_default(),
173        ];
174        writer.write_record(csv_row);
175    }
176    let writer = writer.finish().await?;
177    Ok(writer)
178}