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