Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Export user exercise states #1294

Merged
merged 1 commit into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions services/headless-lms/models/src/course_instances.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,25 @@ WHERE course_id = $1
Ok(course_instances)
}

pub async fn get_course_instance_ids_with_course_id(
conn: &mut PgConnection,
course_id: Uuid,
) -> ModelResult<Vec<Uuid>> {
let res = sqlx::query!(
r#"
SELECT id
FROM course_instances
WHERE course_id = $1
AND deleted_at IS NULL;
"#,
course_id,
)
.map(|r| r.id)
.fetch_all(conn)
.await?;
Ok(res)
}

#[derive(Debug, Serialize)]
#[cfg_attr(feature = "ts_rs", derive(TS))]
pub struct ChapterScore {
Expand Down
7 changes: 5 additions & 2 deletions services/headless-lms/models/src/exercises.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use derive_more::Display;
use futures::future::BoxFuture;
use itertools::Itertools;
use url::Url;
Expand Down Expand Up @@ -130,7 +131,9 @@ Indicates what is the user's completion status for a exercise.

As close as possible to LTI's activity progress for compatibility: <https://www.imsglobal.org/spec/lti-ags/v2p0#activityprogress>.
*/
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default, sqlx::Type)]
#[derive(
Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default, Display, sqlx::Type,
)]
#[cfg_attr(feature = "ts_rs", derive(TS))]
#[sqlx(type_name = "activity_progress", rename_all = "kebab-case")]
pub enum ActivityProgress {
Expand All @@ -154,7 +157,7 @@ Tells what's the status of the grading progress for a user and exercise.
As close as possible LTI's grading progress for compatibility: <https://www.imsglobal.org/spec/lti-ags/v2p0#gradingprogress>
*/
#[derive(
Clone, Copy, Debug, Deserialize, Eq, Serialize, Ord, PartialEq, PartialOrd, sqlx::Type,
Clone, Copy, Debug, Deserialize, Eq, Serialize, Ord, PartialEq, PartialOrd, Display, sqlx::Type,
)]
#[cfg_attr(feature = "ts_rs", derive(TS))]
#[sqlx(type_name = "grading_progress", rename_all = "kebab-case")]
Expand Down
47 changes: 46 additions & 1 deletion services/headless-lms/models/src/user_exercise_states.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use derive_more::Display;
use std::collections::HashMap;

use futures::Stream;
Expand All @@ -13,7 +14,7 @@ use crate::{
user_course_settings,
};

#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type)]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, Display)]
#[sqlx(type_name = "reviewing_stage", rename_all = "snake_case")]
#[cfg_attr(feature = "ts_rs", derive(TS))]
/**
Expand Down Expand Up @@ -1148,6 +1149,50 @@ WHERE exercises.deleted_at IS NULL
Ok(res)
}

#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
#[cfg_attr(feature = "ts_rs", derive(TS))]
pub struct ExportedUserExerciseState {
pub id: Uuid,
pub user_id: Uuid,
pub exercise_id: Uuid,
pub course_instance_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub score_given: Option<f32>,
pub grading_progress: GradingProgress,
pub activity_progress: ActivityProgress,
pub reviewing_stage: ReviewingStage,
pub selected_exercise_slide_id: Option<Uuid>,
}

pub fn stream_user_exercise_states_for_course<'a>(
conn: &'a mut PgConnection,
course_instance_ids: &'a [Uuid],
) -> impl Stream<Item = sqlx::Result<ExportedUserExerciseState>> + 'a {
let res = sqlx::query_as!(
ExportedUserExerciseState,
r#"
SELECT id,
user_id,
exercise_id,
course_instance_id,
created_at,
updated_at,
score_given,
grading_progress AS "grading_progress: _",
activity_progress AS "activity_progress: _",
reviewing_stage AS "reviewing_stage: _",
selected_exercise_slide_id
FROM user_exercise_states
WHERE course_instance_id = ANY($1)
AND deleted_at IS NULL
"#,
course_instance_ids
)
.fetch(conn);
res
}

#[cfg(test)]
mod tests {
use chrono::TimeZone;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Controllers for requests starting with `/api/v0/main-frontend/courses`.

use chrono::Utc;
use domain::csv_export::user_exericse_states_export::UserExerciseStatesExportOperation;
use headless_lms_models::suspected_cheaters::{SuspectedCheaters, ThresholdData};
use std::sync::Arc;

Expand Down Expand Up @@ -1101,6 +1102,44 @@ pub async fn course_consent_form_answers_export(
.await
}

/**
GET `/api/v0/main-frontend/courses/${course.id}/export-user-exercise-states`

gets CSV for course specific user exercise states
*/
#[instrument(skip(pool))]
pub async fn user_exercise_states_export(
course_id: web::Path<Uuid>,
pool: web::Data<PgPool>,
user: AuthUser,
) -> ControllerResult<HttpResponse> {
let mut conn = pool.acquire().await?;

let token = authorize(
&mut conn,
Act::Teach,
Some(user.id),
Res::Course(*course_id),
)
.await?;

let course = models::courses::get_course(&mut conn, *course_id).await?;

general_export(
pool,
&format!(
"attachment; filename=\"Course: {} - User exercise states {}.csv\"",
course.name,
Utc::now().format("%Y-%m-%d")
),
UserExerciseStatesExportOperation {
course_id: *course_id,
},
token,
)
.await
}

/**
GET `/api/v0/main-frontend/courses/${course.id}/page-visit-datum-summary` - Gets aggregated statistics for page visits for the course.
*/
Expand Down Expand Up @@ -1533,6 +1572,10 @@ pub fn _add_routes(cfg: &mut ServiceConfig) {
"/{course_id}/export-course-user-consents",
web::get().to(course_consent_form_answers_export),
)
.route(
"/{course_id}/export-user-exercise-states",
web::get().to(user_exercise_states_export),
)
.route(
"/{course_id}/page-visit-datum-summary",
web::get().to(get_page_visit_datum_summary),
Expand Down
1 change: 1 addition & 0 deletions services/headless-lms/server/src/domain/csv_export/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod course_research_form_questions_answers_export;
pub mod exercise_tasks_export;
pub mod points;
pub mod submissions;
pub mod user_exericse_states_export;
pub mod users_export;

use anyhow::{Context, Result};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
use anyhow::Result;
use bytes::Bytes;

use futures::TryStreamExt;
use headless_lms_models::{course_instances, user_exercise_states};

use async_trait::async_trait;

use crate::domain::csv_export::CsvWriter;

use sqlx::PgConnection;
use std::io::Write;
use tokio::sync::mpsc::UnboundedSender;

use uuid::Uuid;

use crate::prelude::*;

use super::{
super::authorization::{AuthorizationToken, AuthorizedResponse},
CSVExportAdapter, CsvExportDataLoader,
};

pub struct UserExerciseStatesExportOperation {
pub course_id: Uuid,
}

#[async_trait]
impl CsvExportDataLoader for UserExerciseStatesExportOperation {
async fn load_data(
&self,
sender: UnboundedSender<Result<AuthorizedResponse<Bytes>, ControllerError>>,
conn: &mut PgConnection,
token: AuthorizationToken,
) -> anyhow::Result<CSVExportAdapter> {
export_user_exercise_states(
&mut *conn,
self.course_id,
CSVExportAdapter {
sender,
authorization_token: token,
},
)
.await
}
}

/// Writes user exercise states as csv into the writer
pub async fn export_user_exercise_states<W>(
conn: &mut PgConnection,
course_id: Uuid,
writer: W,
) -> Result<W>
where
W: Write + Send + 'static,
{
let course_instance_ids =
course_instances::get_course_instance_ids_with_course_id(conn, course_id).await?;

let headers = IntoIterator::into_iter([
"course_instance_id".to_string(),
"user_id".to_string(),
"exercise_id".to_string(),
"created_at".to_string(),
"updated_at".to_string(),
"activity_process".to_string(),
"grading_progress".to_string(),
"reviewing_stage".to_string(),
"score_given".to_string(),
]);

let mut stream =
user_exercise_states::stream_user_exercise_states_for_course(conn, &course_instance_ids);

let writer = CsvWriter::new_with_initialized_headers(writer, headers).await?;
while let Some(next) = stream.try_next().await? {
let csv_row = vec![
next.course_instance_id.unwrap_or_default().to_string(),
next.user_id.to_string(),
next.exercise_id.to_string(),
next.created_at.to_rfc3339(),
next.updated_at.to_rfc3339(),
next.activity_progress.to_string(),
next.grading_progress.to_string(),
next.reviewing_stage.to_string(),
next.score_given.unwrap_or(0.0).to_string(),
];
writer.write_record(csv_row);
}
let writer = writer.finish().await?;
Ok(writer)
}
Loading
Loading