1pub mod code_giveaway_codes;
2pub mod course_instance_export;
3pub mod course_research_form_questions_answers_export;
4pub mod credit_registrations_export;
5pub mod exercise_tasks_export;
6pub mod points;
7pub mod submissions;
8pub mod user_exercise_states_export;
9pub mod users_export;
10
11use anyhow::{Context, Result};
12use bytes::Bytes;
13use csv::Writer;
14use futures::{Stream, StreamExt, stream::FuturesUnordered};
15
16use async_trait::async_trait;
17
18use models::course_module_completions::CourseModuleCompletionWithRegistrationInfo;
19use serde::Serialize;
20use sqlx::PgConnection;
21use std::{
22 io,
23 io::Write,
24 sync::{
25 Arc, Mutex,
26 atomic::{AtomicBool, Ordering},
27 },
28};
29use tokio::{sync::mpsc::UnboundedSender, task::JoinHandle};
30use tokio_stream::wrappers::UnboundedReceiverStream;
31
32use crate::prelude::*;
33
34use super::authorization::{AuthorizationToken, AuthorizedResponse};
35struct CsvWriter<W: Write> {
37 csv_writer: Arc<Mutex<Writer<W>>>,
38 handles: FuturesUnordered<JoinHandle<Result<()>>>,
39}
40
41impl<W: Write + Send + 'static> CsvWriter<W> {
42 async fn new_with_initialized_headers<I, T>(writer: W, headers: I) -> Result<Self>
44 where
45 I: IntoIterator<Item = T> + Send + 'static,
46 T: AsRef<[u8]>,
47 {
48 let mut writer = csv::WriterBuilder::new()
49 .has_headers(false)
50 .from_writer(writer);
51
52 let writer = tokio::task::spawn_blocking(move || {
54 writer
55 .write_record(headers)
56 .context("Failed to write headers")?;
57 Result::<_, anyhow::Error>::Ok(writer)
58 })
59 .await??;
60
61 Ok(Self {
62 csv_writer: Arc::new(Mutex::new(writer)),
63 handles: FuturesUnordered::new(),
64 })
65 }
66
67 fn write_record<I, T>(&self, csv_row: I)
69 where
70 I: IntoIterator<Item = T> + Send + 'static,
71 T: AsRef<[u8]>,
72 {
73 let writer = self.csv_writer.clone();
74 let handle = tokio::task::spawn_blocking(move || {
75 writer
76 .lock()
77 .map_err(|_| anyhow::anyhow!("Failed to lock mutex"))?
78 .write_record(csv_row)
79 .context("Failed to serialize points")
80 });
81 self.handles.push(handle);
82 }
83
84 async fn finish(mut self) -> Result<W> {
87 while let Some(handle) = self.handles.next().await {
89 handle??;
90 }
91
92 let writer = tokio::task::spawn_blocking(move || {
93 let _ = &self;
94 Arc::try_unwrap(self.csv_writer)
95 .map_err(|_| anyhow::anyhow!("Failed to extract inner writer from arc"))?
96 .into_inner()
97 .map_err(|e| anyhow::anyhow!("Failed to extract inner writer from mutex: {}", e))?
98 .into_inner()
99 .map_err(|e| {
100 anyhow::anyhow!("Failed to extract inner writer from CSV writer: {}", e)
101 })
102 })
103 .await??;
104 Ok(writer)
105 }
106}
107
108fn course_module_completion_info_to_grade_string(
113 input: Option<&CourseModuleCompletionWithRegistrationInfo>,
114) -> String {
115 let grade_string = input.map(|info| {
116 if let Some(grade) = info.grade {
117 return grade.to_string();
118 }
119 if info.passed {
120 return "pass".to_string();
121 };
122 "fail".to_string()
123 });
124 if let Some(grade_string) = grade_string {
125 if let Some(info) = input
126 && !info.prerequisite_modules_completed
127 {
128 return format!("{grade_string}*");
129 }
130 return grade_string;
131 }
132 "-".to_string()
133}
134
135pub struct CSVExportAdapter {
136 pub sender: UnboundedSender<ControllerResult<Bytes>>,
137 pub authorization_token: AuthorizationToken,
138}
139impl Write for CSVExportAdapter {
140 fn flush(&mut self) -> std::io::Result<()> {
141 Ok(())
142 }
143
144 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
145 let bytes = Bytes::copy_from_slice(buf);
146 let token = self.authorization_token;
147 self.sender
148 .send(token.authorized_ok(bytes))
149 .map_err(|e| io::Error::other(e.to_string()))?;
150 Ok(buf.len())
151 }
152}
153
154pub fn make_authorized_streamable(
172 stream: impl Stream<Item = Result<AuthorizedResponse<bytes::Bytes>, ControllerError>>,
173) -> impl Stream<Item = Result<bytes::Bytes, ControllerError>> {
174 stream.map(|item| item.map(|item2| item2.data))
175}
176
177pub fn serializable_sqlx_result_stream_to_json_stream(
181 stream: impl Stream<Item = sqlx::Result<impl Serialize>>,
182) -> impl Stream<Item = Result<bytes::Bytes, ControllerError>> {
183 let emitted_any_item = Arc::new(AtomicBool::new(false));
186 let mark_emitted = Arc::clone(&emitted_any_item);
187 let res_stream = stream.enumerate().map(move |(n, item)| {
188 mark_emitted.store(true, Ordering::Relaxed);
189 item.map(|item2| {
190 match serde_json::to_vec(&item2) {
191 Ok(mut v) => {
192 if n == 0 {
194 v.insert(0, b'[');
196 } else {
197 v.insert(0, b',');
199 }
200 Bytes::from(v)
201 }
202 Err(e) => {
203 error!("Failed to serialize item: {}", e);
206 Bytes::from(format!(
207 "Streaming error: Failed to serialize item. Details: {:?}",
208 e
209 ))
210 }
211 }
212 })
213 .map_err(|original_error| {
214 ControllerError::new(
215 ControllerErrorType::InternalServerError,
216 original_error.to_string(),
217 Some(original_error.into()),
218 )
219 })
220 });
221 res_stream.chain(futures::stream::once(async move {
223 Ok(Bytes::from_static(
224 if emitted_any_item.load(Ordering::Relaxed) {
225 b"]"
226 } else {
227 b"[]"
228 },
229 ))
230 }))
231}
232
233#[async_trait]
234pub trait CsvExportDataLoader {
235 async fn load_data(
236 &self,
237 sender: UnboundedSender<Result<AuthorizedResponse<Bytes>, ControllerError>>,
238 conn: &mut PgConnection,
239 token: AuthorizationToken,
240 ) -> anyhow::Result<CSVExportAdapter>;
241}
242
243pub async fn general_export(
244 pool: web::Data<PgPool>,
245 content_disposition: &str,
246 data_loader: impl CsvExportDataLoader + std::marker::Send + 'static,
247 token: AuthorizationToken,
248) -> ControllerResult<HttpResponse> {
249 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::<ControllerResult<Bytes>>();
250 let mut handle_conn = pool.acquire().await?;
252 let _handle = tokio::spawn(async move {
253 let fut = data_loader.load_data(sender, &mut handle_conn, token);
254 let res = fut.await;
255 if let Err(err) = res {
256 tracing::error!("Failed to export: {}", err);
257 }
258 });
259
260 token.authorized_ok(
262 HttpResponse::Ok()
263 .append_header(("Content-Disposition", content_disposition))
264 .streaming(make_authorized_streamable(UnboundedReceiverStream::new(
265 receiver,
266 ))),
267 )
268}
269
270#[cfg(test)]
271mod test {
272 use std::{collections::HashMap, io::Cursor};
273
274 use headless_lms_models::{
275 course_instance_enrollments, exercise_slides,
276 exercise_task_gradings::ExerciseTaskGradingResult,
277 exercise_tasks::{self, NewExerciseTask},
278 exercises::{self, GradingProgress},
279 library::grading::{
280 GradingPolicy, StudentExerciseSlideSubmission, StudentExerciseTaskSubmission,
281 },
282 user_exercise_states,
283 user_exercise_states::ExerciseWithUserState,
284 users,
285 };
286 use models::chapters::{self, NewChapter};
287 use serde_json::Value;
288
289 use super::*;
290 use crate::{
291 domain::{
292 csv_export::points::export_course_instance_points,
293 models_requests::{self, JwtKey},
294 },
295 test_helper::*,
296 };
297
298 #[actix_web::test]
299 async fn exports() {
300 insert_data!(:tx, :user, :org, :course, :instance, :course_module, :chapter, :page, :exercise, :slide, :task);
301
302 let u2 = users::insert(
303 tx.as_mut(),
304 PKeyPolicy::Generate,
305 "second@example.org",
306 None,
307 None,
308 )
309 .await
310 .unwrap();
311
312 course_instance_enrollments::insert(tx.as_mut(), user, course, instance.id)
313 .await
314 .unwrap();
315 course_instance_enrollments::insert(tx.as_mut(), u2, course, instance.id)
316 .await
317 .unwrap();
318
319 let c2 = chapters::insert(
320 tx.as_mut(),
321 PKeyPolicy::Generate,
322 &NewChapter {
323 name: "".to_string(),
324 color: Some("#065853".to_string()),
325 course_id: course,
326 chapter_number: 2,
327 front_page_id: None,
328 opens_at: None,
329 deadline: None,
330 course_module_id: Some(course_module.id),
331 },
332 )
333 .await
334 .unwrap();
335
336 let e2 = exercises::insert(tx.as_mut(), PKeyPolicy::Generate, course, "", page, c2, 0)
337 .await
338 .unwrap();
339 let s2 = exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, e2, 0)
340 .await
341 .unwrap();
342 let et2 = exercise_tasks::insert(
343 tx.as_mut(),
344 PKeyPolicy::Generate,
345 NewExerciseTask {
346 exercise_slide_id: s2,
347 exercise_type: "".to_string(),
348 assignment: vec![],
349 public_spec: Some(Value::Null),
350 private_spec: Some(Value::Null),
351 model_solution_spec: Some(Value::Null),
352 order_number: 1,
353 },
354 )
355 .await
356 .unwrap();
357
358 let e3 = exercises::insert(tx.as_mut(), PKeyPolicy::Generate, course, "", page, c2, 1)
359 .await
360 .unwrap();
361 let s3 = exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, e3, 0)
362 .await
363 .unwrap();
364 let et3 = exercise_tasks::insert(
365 tx.as_mut(),
366 PKeyPolicy::Generate,
367 NewExerciseTask {
368 exercise_slide_id: s3,
369 exercise_type: "".to_string(),
370 assignment: vec![],
371 public_spec: Some(Value::Null),
372 private_spec: Some(Value::Null),
373 model_solution_spec: Some(Value::Null),
374 order_number: 2,
375 },
376 )
377 .await
378 .unwrap();
379 submit_and_grade(tx.as_mut(), exercise, slide, task, user, course, 12.34).await;
380 submit_and_grade(tx.as_mut(), e2, s2, et2, user, course, 23.45).await;
381 submit_and_grade(tx.as_mut(), e2, s2, et2, u2, course, 34.56).await;
382 submit_and_grade(tx.as_mut(), e3, s3, et3, u2, course, 45.67).await;
383
384 let buf = vec![];
385 let buf = export_course_instance_points(tx.as_mut(), instance.id, buf)
386 .await
387 .unwrap();
388 let buf = Cursor::new(buf);
389
390 let mut reader = csv::Reader::from_reader(buf);
391 let mut count = 0;
392 for record in reader.records() {
393 count += 1;
394 let record = record.unwrap();
395 println!("{}", record.as_slice());
396 let user_id = Uuid::parse_str(&record[0]).unwrap();
397 let first = record[1].parse::<f32>().unwrap();
398 let second = record[2].parse::<f32>().unwrap();
399 if user_id == user {
400 assert!((first - 0.1234).abs() < 0.1 && (second - 0.2345).abs() < 0.1);
401 } else if user_id == u2 {
402 assert!((first - 0.0).abs() < 0.1 && (second - 0.8023).abs() < 0.1);
403 } else {
404 panic!("unexpected user id");
405 }
406 }
407 assert_eq!(count, 2)
408 }
409
410 async fn submit_and_grade(
411 tx: &mut PgConnection,
412 ex: Uuid,
413 ex_slide: Uuid,
414 et: Uuid,
415 u: Uuid,
416 course_id: Uuid,
417 score_given: f32,
418 ) {
419 let exercise = exercises::get_by_id(tx, ex).await.unwrap();
420 user_exercise_states::get_or_create_user_exercise_state(tx, u, ex, Some(course_id), None)
421 .await
422 .unwrap();
423 user_exercise_states::upsert_selected_exercise_slide_id(
424 tx,
425 u,
426 ex,
427 Some(course_id),
428 None,
429 Some(ex_slide),
430 )
431 .await
432 .unwrap();
433 let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
434 tx,
435 u,
436 ex,
437 Some(course_id),
438 None,
439 )
440 .await
441 .unwrap();
442 let mut exercise_with_user_state =
443 ExerciseWithUserState::new(exercise, user_exercise_state).unwrap();
444 let jwt_key = Arc::new(JwtKey::test_key());
445 let app_conf = crate::test_helper::init_app_conf().expect("app conf");
446 headless_lms_models::library::grading::grade_user_submission(
447 tx,
448 &mut exercise_with_user_state,
449 &StudentExerciseSlideSubmission {
450 exercise_slide_id: ex_slide,
451 exercise_task_submissions: vec![StudentExerciseTaskSubmission::json(
452 et,
453 Value::Null,
454 )],
455 },
456 GradingPolicy::Fixed(HashMap::from([(
457 et,
458 ExerciseTaskGradingResult {
459 feedback_json: None,
460 feedback_text: None,
461 grading_progress: GradingProgress::FullyGraded,
462 score_given,
463 score_maximum: 100,
464 set_user_variables: Some(HashMap::new()),
465 },
466 )])),
467 models_requests::fetch_service_info,
468 models_requests::make_grading_request_sender(jwt_key, app_conf.base_url.clone()),
469 &crate::test_helper::init_file_store(),
470 &app_conf,
471 )
472 .await
473 .unwrap();
474 }
475}