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