headless_lms_server/controllers/
langs.rs

1/*!
2Handlers for HTTP requests to `/api/v0/langs`. Contains endpoints for the use of tmc-langs.
3
4*/
5use crate::controllers::helpers::file_uploading;
6use crate::domain::langs::{convert::Convert, token::AuthToken};
7use crate::domain::models_requests::{self, JwtKey};
8use crate::prelude::*;
9use actix_multipart::form::MultipartForm;
10use actix_multipart::form::json::Json as MultipartJson;
11use actix_multipart::form::tempfile::TempFile;
12use headless_lms_utils::file_store::file_utils;
13use models::chapters::DatabaseChapter;
14use models::library::grading::{StudentExerciseSlideSubmission, StudentExerciseTaskSubmission};
15use models::user_exercise_states::CourseInstanceOrExamId;
16use mooc_langs_api as api;
17use std::collections::HashSet;
18
19/**
20 * GET /api/v0/langs/course-instances
21 *
22 * Returns the course instances that the user is currently enrolled on that contain TMC exercises.
23 */
24#[instrument(skip(pool))]
25async fn get_course_instances(
26    pool: web::Data<PgPool>,
27    user: AuthToken,
28) -> ControllerResult<web::Json<Vec<api::CourseInstance>>> {
29    let mut conn = pool.acquire().await?;
30
31    let course_instances =
32        models::course_instances::get_enrolled_course_instances_for_user_with_exercise_type(
33            &mut conn, user.id, "tmc",
34        )
35        .await?
36        .convert();
37
38    // if the user is enrolled on the course, they should be able to view it regardless of permissions
39    let token = skip_authorize();
40    token.authorized_ok(web::Json(course_instances))
41}
42
43/**
44 * GET /api/v0/langs/course-instances/:id/exercises
45 *
46 * Returns the user's exercise slides for the given course instance.
47 * Does not return anything for chapters which are not open yet.
48 * Selects slides for exercises with no slide selected yet.
49 * Only returns slides which have tasks that are compatible with langs.
50 */
51#[instrument(skip(pool))]
52async fn get_course_instance_exercises(
53    pool: web::Data<PgPool>,
54    user: AuthToken,
55    course_instance: web::Path<Uuid>,
56) -> ControllerResult<web::Json<Vec<api::ExerciseSlide>>> {
57    let mut conn = pool.acquire().await?;
58    let token = authorize(
59        &mut conn,
60        Act::View,
61        Some(user.id),
62        Res::CourseInstance(*course_instance),
63    )
64    .await?;
65
66    let mut slides = Vec::new();
67    // process only exercises of open chapters
68    let open_chapter_ids = models::chapters::course_instance_chapters(&mut conn, *course_instance)
69        .await?
70        .into_iter()
71        .filter(DatabaseChapter::has_opened)
72        .map(|c| c.id)
73        .collect::<HashSet<_>>();
74    let open_chapter_exercises =
75        models::exercises::get_exercises_by_course_instance_id(&mut conn, *course_instance)
76            .await?
77            .into_iter()
78            .filter(|e| {
79                e.chapter_id
80                    .map(|ci| open_chapter_ids.contains(&ci))
81                    .unwrap_or_default()
82            });
83    for open_exercise in open_chapter_exercises {
84        let (slide, _) = models::exercises::get_or_select_exercise_slide(
85            &mut conn,
86            Some(user.id),
87            &open_exercise,
88            models_requests::fetch_service_info,
89        )
90        .await?;
91        let tasks: Vec<api::ExerciseTask> = slide
92            .exercise_tasks
93            .into_iter()
94            // filter out all non-tmc tasks
95            .filter(|et| et.exercise_service_slug == "tmc")
96            // TODO: hide model solutions for unsolved tasks
97            .map(|mut et| {
98                et.model_solution_spec = None;
99                et
100            })
101            .map(Convert::convert)
102            .collect();
103        // do not include slides with no tmc tasks
104        if !tasks.is_empty() {
105            slides.push(api::ExerciseSlide {
106                slide_id: slide.id,
107                exercise_id: open_exercise.id,
108                exercise_name: open_exercise.name,
109                exercise_order_number: open_exercise.order_number,
110                deadline: open_exercise.deadline,
111                tasks,
112            });
113        }
114    }
115
116    token.authorized_ok(web::Json(slides))
117}
118
119/**
120 * GET /api/v0/langs/exercises/:id
121 *
122 * Returns an exercise slide for the user for the given exercise.
123 *
124 * Only returns slides
125 */
126#[instrument(skip(pool))]
127async fn get_exercise(
128    pool: web::Data<PgPool>,
129    user: AuthToken,
130    exercise_id: web::Path<Uuid>,
131) -> ControllerResult<web::Json<api::ExerciseSlide>> {
132    let mut conn = pool.acquire().await?;
133    let token = authorize(
134        &mut conn,
135        Act::View,
136        Some(user.id),
137        Res::Exercise(*exercise_id),
138    )
139    .await?;
140
141    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
142    let (exercise_slide, instance_or_exam_id) = models::exercises::get_or_select_exercise_slide(
143        &mut conn,
144        Some(user.id),
145        &exercise,
146        models_requests::fetch_service_info,
147    )
148    .await?;
149    match instance_or_exam_id {
150        Some(CourseInstanceOrExamId::Instance(_id)) => {}
151        _ => {
152            return Err(ControllerError::new(
153                ControllerErrorType::BadRequest,
154                "User is not enrolled to this exercise's course".to_string(),
155                None,
156            ));
157        }
158    }
159
160    token.authorized_ok(web::Json(api::ExerciseSlide {
161        slide_id: exercise_slide.id,
162        exercise_id: exercise.id,
163        exercise_name: exercise.name,
164        exercise_order_number: exercise.order_number,
165        deadline: exercise.deadline,
166        tasks: exercise_slide
167            .exercise_tasks
168            .into_iter()
169            .map(Convert::convert)
170            .collect(),
171    }))
172}
173
174#[derive(MultipartForm)]
175struct SubmissionForm {
176    submission: MultipartJson<api::ExerciseSlideSubmission>,
177    file: TempFile,
178}
179
180/**
181 * POST /api/v0/langs/exercises/:id/submit
182 *
183 * Accepts an exercise submission from the user.
184 */
185async fn submit_exercise(
186    pool: web::Data<PgPool>,
187    file_store: web::Data<dyn FileStore>,
188    jwt_key: web::Data<JwtKey>,
189    exercise_id: web::Path<Uuid>,
190    submission: MultipartForm<SubmissionForm>,
191    user: AuthToken,
192    app_conf: web::Data<ApplicationConfiguration>,
193) -> ControllerResult<web::Json<api::ExerciseTaskSubmissionResult>> {
194    let mut conn = pool.acquire().await?;
195    let token = authorize(
196        &mut conn,
197        Act::View,
198        Some(user.id),
199        Res::Exercise(*exercise_id),
200    )
201    .await?;
202
203    // first get all the relevant data
204    let submission_form = submission.into_inner();
205    let submission = submission_form.submission.into_inner();
206    let temp_file = submission_form.file;
207    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
208    let course_id = exercise
209        .course_id
210        .ok_or_else(|| anyhow::anyhow!("Cannot answer non-course exercises"))?;
211    let exercise_slide =
212        models::exercise_slides::get_exercise_slide(&mut conn, submission.exercise_slide_id)
213            .await?;
214    let exercise_task =
215        models::exercise_tasks::get_exercise_task_by_id(&mut conn, submission.exercise_task_id)
216            .await?;
217
218    // upload the exercise file
219    let file = temp_file.file.into_file();
220    let mime = temp_file
221        .content_type
222        .ok_or_else(|| anyhow::anyhow!("Missing content-type header"))?;
223    let contents = file_utils::file_to_payload(file);
224    let (_upload_id, upload_path) = file_uploading::upload_exercise_archive(
225        &mut conn,
226        contents,
227        file_store.as_ref(),
228        file_uploading::ExerciseTaskInfo {
229            course_id,
230            exercise: &exercise,
231            exercise_slide: &exercise_slide,
232            exercise_task: &exercise_task,
233        },
234        mime,
235        user.id,
236    )
237    .await?;
238
239    // send submission to the exercise service
240    let download_url = file_store.get_download_url(&upload_path, app_conf.as_ref());
241    // `services/tmc/src/util/stateInterfaces.ts/EditorAnswer
242    let data_json = serde_json::json!({
243        "type": "editor",
244        "archive_download_url": download_url
245    });
246    let result = domain::exercises::process_submission(
247        &mut conn,
248        user.id,
249        exercise,
250        &StudentExerciseSlideSubmission {
251            exercise_slide_id: submission.exercise_slide_id,
252            exercise_task_submissions: vec![StudentExerciseTaskSubmission {
253                exercise_task_id: submission.exercise_task_id,
254                data_json,
255            }],
256        },
257        jwt_key.into_inner(),
258    )
259    .await?;
260
261    // the input only contains one task submission, so the task results should only contain one result as well
262    let submission = result
263        .exercise_task_submission_results
264        .into_iter()
265        .next()
266        .ok_or_else(|| {
267            ControllerError::new(
268                ControllerErrorType::InternalServerError,
269                "Failed to find exercise task submission id".to_string(),
270                None,
271            )
272        })?;
273    let result = api::ExerciseTaskSubmissionResult {
274        submission_id: submission.submission.id,
275    };
276    token.authorized_ok(web::Json(result))
277}
278
279async fn get_submission_grading(
280    pool: web::Data<PgPool>,
281    submission_id: web::Path<Uuid>,
282    user: AuthToken,
283) -> ControllerResult<web::Json<api::ExerciseTaskSubmissionStatus>> {
284    let mut conn = pool.acquire().await?;
285    let token = authorize(
286        &mut conn,
287        Act::View,
288        Some(user.id),
289        Res::ExerciseTaskSubmission(*submission_id),
290    )
291    .await?;
292
293    let grading = models::exercise_task_gradings::get_by_exercise_task_submission_id(
294        &mut conn,
295        *submission_id,
296    )
297    .await?;
298    let status = match grading {
299        Some(grading) => api::ExerciseTaskSubmissionStatus::Grading {
300            grading_progress: grading.grading_progress.convert(),
301            score_given: grading.score_given,
302            grading_started_at: grading.grading_started_at,
303            grading_completed_at: grading.grading_completed_at,
304            feedback_json: grading.feedback_json,
305            feedback_text: grading.feedback_text,
306        },
307        None => api::ExerciseTaskSubmissionStatus::NoGradingYet,
308    };
309    token.authorized_ok(web::Json(status))
310}
311
312pub fn _add_routes(cfg: &mut ServiceConfig) {
313    cfg.route("/course-instances", web::get().to(get_course_instances))
314        .route(
315            "/course-instances/{id}/exercises",
316            web::get().to(get_course_instance_exercises),
317        )
318        .route("/exercises/{id}", web::get().to(get_exercise))
319        .route("/exercises/{id}/submit", web::post().to(submit_exercise))
320        .route(
321            "/submissions/{id}/grading",
322            web::get().to(get_submission_grading),
323        );
324}