headless_lms_models/library/
grading.rs

1//! Collection of functions used for processing and evaluating user submissions for exercises.
2
3use futures::future::BoxFuture;
4use std::collections::HashMap;
5use url::Url;
6
7use crate::{
8    exercise_service_info::ExerciseServiceInfoApi,
9    exercise_slide_submissions::{self, ExerciseSlideSubmission, NewExerciseSlideSubmission},
10    exercise_task_gradings::{
11        self, ExerciseTaskGrading, ExerciseTaskGradingResult, UserPointsUpdateStrategy,
12    },
13    exercise_task_regrading_submissions::ExerciseTaskRegradingSubmission,
14    exercise_task_submissions::{self, ExerciseTaskSubmission},
15    exercise_tasks::{self, CourseMaterialExerciseTask, ExerciseTask},
16    exercises::{self, Exercise, ExerciseStatus, GradingProgress},
17    flagged_answers::{self, FlaggedAnswer},
18    peer_or_self_review_configs::PeerReviewProcessingStrategy,
19    peer_or_self_review_question_submissions::{
20        self, PeerOrSelfReviewQuestionSubmission, PeerReviewWithQuestionsAndAnswers,
21    },
22    prelude::*,
23    regradings,
24    user_course_exercise_service_variables::UserCourseExerciseServiceVariable,
25    user_exercise_slide_states::{self, UserExerciseSlideState},
26    user_exercise_states::{self, ExerciseWithUserState, UserExerciseState},
27    user_exercise_task_states,
28};
29
30use super::user_exercise_state_updater;
31
32/// Contains data sent by the student when they make a submission for an exercise slide.
33#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
34#[cfg_attr(feature = "ts_rs", derive(TS))]
35pub struct StudentExerciseSlideSubmission {
36    pub exercise_slide_id: Uuid,
37    pub exercise_task_submissions: Vec<StudentExerciseTaskSubmission>,
38}
39
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
41#[cfg_attr(feature = "ts_rs", derive(TS))]
42pub struct StudentExerciseSlideSubmissionResult {
43    pub exercise_status: Option<ExerciseStatus>,
44    pub exercise_task_submission_results: Vec<StudentExerciseTaskSubmissionResult>,
45    pub user_course_instance_exercise_service_variables: Vec<UserCourseExerciseServiceVariable>,
46}
47
48impl StudentExerciseSlideSubmissionResult {
49    pub fn clear_grading_information(&mut self) {
50        self.exercise_status = None;
51        self.exercise_task_submission_results
52            .iter_mut()
53            .for_each(|result| {
54                result.grading = None;
55                result.model_solution_spec = None;
56            });
57    }
58
59    pub fn clear_model_solution_specs(&mut self) {
60        self.exercise_task_submission_results
61            .iter_mut()
62            .for_each(|result| {
63                result.model_solution_spec = None;
64            })
65    }
66}
67
68#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
69#[cfg_attr(feature = "ts_rs", derive(TS))]
70pub struct StudentExerciseTaskSubmission {
71    pub exercise_task_id: Uuid,
72    pub data_json: serde_json::Value,
73}
74
75#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
76#[cfg_attr(feature = "ts_rs", derive(TS))]
77pub struct StudentExerciseTaskSubmissionResult {
78    pub submission: ExerciseTaskSubmission,
79    pub grading: Option<ExerciseTaskGrading>,
80    pub model_solution_spec: Option<serde_json::Value>,
81    pub exercise_task_exercise_service_slug: String,
82}
83
84#[derive(Debug)]
85pub struct ExerciseSlideSubmissionWithTasks {
86    pub exercise_slide_submission: ExerciseSlideSubmission,
87    pub exercise_slide_submission_tasks: Vec<ExerciseTaskSubmission>,
88}
89
90/// If passed to to an exercise state update, it will update the peer review status with the given information
91#[derive(Debug)]
92pub struct ExerciseStateUpdateNeedToUpdatePeerReviewStatusWithThis {
93    pub given_enough_peer_reviews: bool,
94    pub received_enough_peer_reviews: bool,
95    pub peer_review_processing_strategy: PeerReviewProcessingStrategy,
96    pub peer_review_accepting_threshold: f32,
97    /// Used to for calculating averages when acting on PeerReviewProcessingStrategy
98    pub received_peer_or_self_review_question_submissions: Vec<PeerOrSelfReviewQuestionSubmission>,
99}
100
101/// Inserts user submission to database. Tasks within submission are validated to make sure that
102/// they belong to the correct exercise slide.
103pub async fn create_user_exercise_slide_submission(
104    conn: &mut PgConnection,
105    exercise_with_user_state: &ExerciseWithUserState,
106    user_exercise_slide_submission: &StudentExerciseSlideSubmission,
107) -> ModelResult<ExerciseSlideSubmissionWithTasks> {
108    let selected_exercise_slide_id = exercise_with_user_state
109        .user_exercise_state()
110        .selected_exercise_slide_id
111        .ok_or_else(|| {
112            ModelError::new(
113                ModelErrorType::PreconditionFailed,
114                "Exercise slide not selected for the student.".to_string(),
115                None,
116            )
117        })?;
118    let exercise_tasks: HashMap<Uuid, ExerciseTask> =
119        exercise_tasks::get_exercise_tasks_by_exercise_slide_id(conn, &selected_exercise_slide_id)
120            .await?;
121    let user_points_update_strategy = if exercise_with_user_state.is_exam_exercise() {
122        UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints
123    } else {
124        UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints
125    };
126
127    let mut tx = conn.begin().await?;
128
129    let exercise_slide_submission = exercise_slide_submissions::insert_exercise_slide_submission(
130        &mut tx,
131        NewExerciseSlideSubmission {
132            exercise_slide_id: selected_exercise_slide_id,
133            course_id: exercise_with_user_state.exercise().course_id,
134            exam_id: exercise_with_user_state.exercise().exam_id,
135            exercise_id: exercise_with_user_state.exercise().id,
136            user_id: exercise_with_user_state.user_exercise_state().user_id,
137            user_points_update_strategy,
138        },
139    )
140    .await?;
141    let user_exercise_task_submissions = &user_exercise_slide_submission.exercise_task_submissions;
142    let mut exercise_slide_submission_tasks =
143        Vec::with_capacity(user_exercise_task_submissions.len());
144    for task_submission in user_exercise_task_submissions {
145        let exercise_task = exercise_tasks
146            .get(&task_submission.exercise_task_id)
147            .ok_or_else(|| {
148                ModelError::new(
149                    ModelErrorType::PreconditionFailed,
150                    "Attempting to submit exercise for illegal exercise_task_id.".to_string(),
151                    None,
152                )
153            })?;
154        let submission_id = exercise_task_submissions::insert(
155            &mut tx,
156            PKeyPolicy::Generate,
157            exercise_slide_submission.id,
158            exercise_task.exercise_slide_id,
159            exercise_task.id,
160            &task_submission.data_json,
161        )
162        .await?;
163        let submission = exercise_task_submissions::get_by_id(&mut tx, submission_id).await?;
164        exercise_slide_submission_tasks.push(submission)
165    }
166
167    tx.commit().await?;
168    Ok(ExerciseSlideSubmissionWithTasks {
169        exercise_slide_submission,
170        exercise_slide_submission_tasks,
171    })
172}
173
174// Relocated regrading logic to condensate score update logic in a single place.
175// Needs better separation of concerns in the far future.
176pub async fn update_grading_with_single_regrading_result(
177    conn: &mut PgConnection,
178    exercise: &Exercise,
179    regrading_submission: &ExerciseTaskRegradingSubmission,
180    exercise_task_grading: &ExerciseTaskGrading,
181    exercise_task_grading_result: &ExerciseTaskGradingResult,
182) -> ModelResult<()> {
183    let task_submission = exercise_task_submissions::get_by_id(
184        &mut *conn,
185        regrading_submission.exercise_task_submission_id,
186    )
187    .await?;
188    let slide_submission = exercise_slide_submissions::get_by_id(
189        &mut *conn,
190        task_submission.exercise_slide_submission_id,
191    )
192    .await?;
193    let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
194        conn,
195        slide_submission.user_id,
196        exercise.id,
197        slide_submission.course_id,
198        slide_submission.exam_id,
199    )
200    .await?;
201    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
202        &mut *conn,
203        user_exercise_state.id,
204        slide_submission.exercise_slide_id,
205    )
206    .await?;
207    let regrading = regradings::get_by_id(&mut *conn, regrading_submission.regrading_id).await?;
208    propagate_user_exercise_state_update_from_exercise_task_grading_result(
209        conn,
210        exercise,
211        exercise_task_grading,
212        exercise_task_grading_result,
213        user_exercise_slide_state,
214        regrading.user_points_update_strategy,
215    )
216    .await?;
217    Ok(())
218}
219
220pub enum GradingPolicy {
221    /// Grades exercise tasks by sending a request to their respective services.
222    Default,
223    /// Intended for test purposes only.
224    Fixed(HashMap<Uuid, ExerciseTaskGradingResult>),
225}
226
227pub async fn grade_user_submission(
228    conn: &mut PgConnection,
229    exercise_with_user_state: &mut ExerciseWithUserState,
230    user_exercise_slide_submission: &StudentExerciseSlideSubmission,
231    grading_policy: GradingPolicy,
232    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
233    send_grading_request: impl Fn(
234        Url,
235        &ExerciseTask,
236        &ExerciseTaskSubmission,
237    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
238) -> ModelResult<StudentExerciseSlideSubmissionResult> {
239    let mut tx = conn.begin().await?;
240
241    let ExerciseSlideSubmissionWithTasks {
242        exercise_slide_submission,
243        exercise_slide_submission_tasks,
244    } = create_user_exercise_slide_submission(
245        &mut tx,
246        exercise_with_user_state,
247        user_exercise_slide_submission,
248    )
249    .await?;
250    let user_exercise_state = exercise_with_user_state.user_exercise_state();
251    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
252        &mut tx,
253        user_exercise_state.id,
254        exercise_slide_submission.exercise_slide_id,
255    )
256    .await?;
257    let results = match grading_policy {
258        GradingPolicy::Default => {
259            let mut results = Vec::with_capacity(exercise_slide_submission_tasks.len());
260            for task_submission in exercise_slide_submission_tasks {
261                let submission = grade_user_submission_task(
262                    &mut tx,
263                    &task_submission,
264                    exercise_with_user_state.exercise(),
265                    user_exercise_slide_state.id,
266                    user_exercise_state,
267                    &fetch_service_info,
268                    &send_grading_request,
269                )
270                .await;
271                match submission {
272                    Ok(submission) => results.push(submission),
273                    Err(err) => {
274                        // Store rejected submission when HTTP call fails
275                        let (http_status_code, error_message, response_body) =
276                            match err.error_type() {
277                                crate::error::ModelErrorType::HttpRequest {
278                                    status_code,
279                                    response_body,
280                                } => (
281                                    Some(*status_code as i32),
282                                    Some(response_body.clone()),
283                                    Some(response_body.clone()),
284                                ),
285                                crate::error::ModelErrorType::HttpError {
286                                    error_type: crate::error::HttpErrorType::ResponseDecodeFailed,
287                                    response_body,
288                                    status_code,
289                                    ..
290                                } => (
291                                    status_code.map(|s| s as i32),
292                                    Some(err.to_string()),
293                                    response_body.clone(),
294                                ),
295                                _ => (None, Some(err.to_string()), None),
296                            };
297
298                        // We want to save the rejected submission to the database
299                        // But don't want to keep the rest of the stuff we have inserted into the database
300                        tx.rollback().await?;
301                        let mut tx = conn.begin().await?;
302
303                        let _ = crate::rejected_exercise_slide_submissions::insert_rejected_exercise_slide_submission(
304                            &mut tx,
305                            user_exercise_slide_submission,
306                            user_exercise_state.user_id,
307                            http_status_code,
308                            error_message,
309                            response_body,
310                        ).await;
311
312                        tx.commit().await?;
313
314                        return Err(err);
315                    }
316                }
317            }
318            results
319        }
320        GradingPolicy::Fixed(fixed_results) => {
321            let mut results = Vec::with_capacity(exercise_slide_submission_tasks.len());
322            for task_submission in exercise_slide_submission_tasks {
323                let fixed_result = fixed_results
324                    .get(&task_submission.exercise_task_id)
325                    .ok_or_else(|| {
326                        ModelError::new(
327                            ModelErrorType::Generic,
328                            "Could not find fixed test result for testing".to_string(),
329                            None,
330                        )
331                    })?
332                    .clone();
333                let submission = create_fixed_grading_for_submission_task(
334                    &mut tx,
335                    &task_submission,
336                    exercise_with_user_state.exercise(),
337                    user_exercise_slide_state.id,
338                    &fixed_result,
339                )
340                .await?;
341                results.push(submission);
342            }
343            results
344        }
345    };
346    let user_exercise_state = update_user_exercise_slide_state_and_user_exercise_state(
347        &mut tx,
348        user_exercise_slide_state,
349        exercise_slide_submission.user_points_update_strategy,
350    )
351    .await?;
352
353    let course_or_exam_id = CourseOrExamId::from_course_and_exam_ids(
354        user_exercise_state.course_id,
355        user_exercise_state.exam_id,
356    )?;
357
358    let user_course_instance_exercise_service_variables  = crate::user_course_exercise_service_variables::get_all_variables_for_user_and_course_or_exam(&mut tx, user_exercise_state.user_id, course_or_exam_id).await?;
359
360    let result = StudentExerciseSlideSubmissionResult {
361        exercise_status: Some(ExerciseStatus {
362            score_given: user_exercise_state.score_given,
363            activity_progress: user_exercise_state.activity_progress,
364            grading_progress: user_exercise_state.grading_progress,
365            reviewing_stage: user_exercise_state.reviewing_stage,
366        }),
367        exercise_task_submission_results: results,
368        user_course_instance_exercise_service_variables,
369    };
370    exercise_with_user_state.set_user_exercise_state(user_exercise_state)?;
371    tx.commit().await?;
372    Ok(result)
373}
374
375async fn grade_user_submission_task(
376    conn: &mut PgConnection,
377    submission: &ExerciseTaskSubmission,
378    exercise: &Exercise,
379    user_exercise_slide_state_id: Uuid,
380    user_exercise_state: &UserExerciseState,
381    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
382    send_grading_request: impl Fn(
383        Url,
384        &ExerciseTask,
385        &ExerciseTaskSubmission,
386    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
387) -> ModelResult<StudentExerciseTaskSubmissionResult> {
388    let grading = exercise_task_gradings::new_grading(conn, exercise, submission).await?;
389    let updated_submission =
390        exercise_task_submissions::set_grading_id(conn, grading.id, submission.id).await?;
391    let exercise_task =
392        exercise_tasks::get_exercise_task_by_id(conn, submission.exercise_task_id).await?;
393    let grading = exercise_task_gradings::grade_submission(
394        conn,
395        submission,
396        &exercise_task,
397        exercise,
398        &grading,
399        user_exercise_state,
400        fetch_service_info,
401        send_grading_request,
402    )
403    .await?;
404    user_exercise_task_states::upsert_with_grading(conn, user_exercise_slide_state_id, &grading)
405        .await?;
406    let model_solution_spec = exercise_tasks::get_exercise_task_model_solution_spec_by_id(
407        conn,
408        submission.exercise_task_id,
409    )
410    .await?;
411
412    Ok(StudentExerciseTaskSubmissionResult {
413        submission: updated_submission,
414        grading: Some(grading),
415        model_solution_spec,
416        exercise_task_exercise_service_slug: exercise_task.exercise_type,
417    })
418}
419
420async fn create_fixed_grading_for_submission_task(
421    conn: &mut PgConnection,
422    submission: &ExerciseTaskSubmission,
423    exercise: &Exercise,
424    user_exercise_slide_state_id: Uuid,
425    fixed_result: &ExerciseTaskGradingResult,
426) -> ModelResult<StudentExerciseTaskSubmissionResult> {
427    let grading = exercise_task_gradings::new_grading(conn, exercise, submission).await?;
428    let updated_submission =
429        exercise_task_submissions::set_grading_id(conn, grading.id, submission.id).await?;
430    let updated_grading =
431        exercise_task_gradings::update_grading(conn, &grading, fixed_result, exercise).await?;
432    user_exercise_task_states::upsert_with_grading(
433        conn,
434        user_exercise_slide_state_id,
435        &updated_grading,
436    )
437    .await?;
438    let exercise_task =
439        exercise_tasks::get_exercise_task_by_id(conn, submission.exercise_task_id).await?;
440    let model_solution_spec = exercise_task.model_solution_spec;
441
442    Ok(StudentExerciseTaskSubmissionResult {
443        submission: updated_submission,
444        grading: Some(grading),
445        model_solution_spec,
446        exercise_task_exercise_service_slug: exercise_task.exercise_type,
447    })
448}
449
450/// Updates the user exercise state starting from a slide state, and propagates the update up to the
451/// whole user exercise state.
452async fn update_user_exercise_slide_state_and_user_exercise_state(
453    conn: &mut PgConnection,
454    user_exercise_slide_state: UserExerciseSlideState,
455    user_points_update_strategy: UserPointsUpdateStrategy,
456) -> ModelResult<UserExerciseState> {
457    update_user_exercise_slide_state(
458        conn,
459        &user_exercise_slide_state,
460        user_points_update_strategy,
461    )
462    .await?;
463    let user_exercise_state = user_exercise_state_updater::update_user_exercise_state(
464        conn,
465        user_exercise_slide_state.user_exercise_state_id,
466    )
467    .await?;
468
469    Ok(user_exercise_state)
470}
471
472async fn update_user_exercise_slide_state(
473    conn: &mut PgConnection,
474    user_exercise_slide_state: &UserExerciseSlideState,
475    user_points_update_strategy: UserPointsUpdateStrategy,
476) -> ModelResult<()> {
477    let (points_from_tasks, grading_progress) =
478        user_exercise_task_states::get_grading_summary_by_user_exercise_slide_state_id(
479            conn,
480            user_exercise_slide_state.id,
481        )
482        .await?;
483    let new_score_given = user_exercise_task_states::figure_out_new_score_given(
484        user_exercise_slide_state.score_given,
485        points_from_tasks,
486        user_points_update_strategy,
487    );
488    let changes = user_exercise_slide_states::update(
489        conn,
490        user_exercise_slide_state.id,
491        new_score_given,
492        grading_progress,
493    )
494    .await?;
495    info!(
496        "Updating user exercise slide state {} affected {} rows.",
497        user_exercise_slide_state.id, changes
498    );
499    Ok(())
500}
501
502/// Updates the user exercise state starting from a single task, and propagates the update up to the
503/// whole user exercise state.
504pub async fn propagate_user_exercise_state_update_from_exercise_task_grading_result(
505    conn: &mut PgConnection,
506    exercise: &Exercise,
507    exercise_task_grading: &ExerciseTaskGrading,
508    exercise_task_grading_result: &ExerciseTaskGradingResult,
509    user_exercise_slide_state: UserExerciseSlideState,
510    user_points_update_strategy: UserPointsUpdateStrategy,
511) -> ModelResult<UserExerciseState> {
512    let updated_exercise_task_grading = exercise_task_gradings::update_grading(
513        conn,
514        exercise_task_grading,
515        exercise_task_grading_result,
516        exercise,
517    )
518    .await?;
519    exercise_task_submissions::set_grading_id(
520        conn,
521        updated_exercise_task_grading.id,
522        updated_exercise_task_grading.exercise_task_submission_id,
523    )
524    .await?;
525    let user_exercise_task_state = user_exercise_task_states::upsert_with_grading(
526        conn,
527        user_exercise_slide_state.id,
528        &updated_exercise_task_grading,
529    )
530    .await?;
531    let user_exercise_slide_state = user_exercise_slide_states::get_by_id(
532        conn,
533        user_exercise_task_state.user_exercise_slide_state_id,
534    )
535    .await?;
536    let user_exercise_state = update_user_exercise_slide_state_and_user_exercise_state(
537        conn,
538        user_exercise_slide_state,
539        user_points_update_strategy,
540    )
541    .await?;
542    Ok(user_exercise_state)
543}
544
545#[derive(Debug, Serialize)]
546#[cfg_attr(feature = "ts_rs", derive(TS))]
547pub struct AnswersRequiringAttention {
548    pub exercise_max_points: i32,
549    pub data: Vec<AnswerRequiringAttentionWithTasks>,
550    pub total_pages: u32,
551}
552
553#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
554#[cfg_attr(feature = "ts_rs", derive(TS))]
555pub struct AnswerRequiringAttentionWithTasks {
556    pub id: Uuid,
557    pub user_id: Uuid,
558    pub created_at: DateTime<Utc>,
559    pub updated_at: DateTime<Utc>,
560    pub deleted_at: Option<DateTime<Utc>>,
561    pub data_json: Option<serde_json::Value>,
562    pub grading_progress: GradingProgress,
563    pub score_given: Option<f32>,
564    pub submission_id: Uuid,
565    pub exercise_id: Uuid,
566    pub tasks: Vec<CourseMaterialExerciseTask>,
567    pub given_peer_reviews: Vec<PeerReviewWithQuestionsAndAnswers>,
568    pub received_peer_or_self_reviews: Vec<PeerReviewWithQuestionsAndAnswers>,
569    pub received_peer_review_flagging_reports: Vec<FlaggedAnswer>,
570}
571
572/// Gets submissions that require input from the teacher to continue processing.
573pub async fn get_paginated_answers_requiring_attention_for_exercise(
574    conn: &mut PgConnection,
575    exercise_id: Uuid,
576    pagination: Pagination,
577    viewer_user_id: Uuid,
578    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
579) -> ModelResult<AnswersRequiringAttention> {
580    let exercise = exercises::get_exercise_by_id(conn, exercise_id).await?;
581    let answer_requiring_attention_count =
582        exercise_slide_submissions::answer_requiring_attention_count(conn, exercise_id).await?;
583    let data = exercise_slide_submissions::get_all_answers_requiring_attention(
584        conn,
585        exercise.id,
586        pagination,
587    )
588    .await?;
589    let mut answers = Vec::with_capacity(data.len());
590    for answer in &data {
591        let tasks = exercise_task_submissions::get_exercise_task_submission_info_by_exercise_slide_submission_id(
592            conn,
593            answer.submission_id,
594            viewer_user_id,
595            &fetch_service_info,
596            false,
597        )
598        .await?;
599        let given_peer_reviews = peer_or_self_review_question_submissions::get_given_peer_reviews(
600            conn,
601            answer.user_id,
602            answer.exercise_id,
603        )
604        .await?;
605
606        let received_peer_or_self_reviews =
607            peer_or_self_review_question_submissions::get_questions_and_answers_by_submission_id(
608                conn,
609                answer.submission_id,
610            )
611            .await?;
612        let received_peer_review_flagging_reports: Vec<FlaggedAnswer> =
613            flagged_answers::get_flagged_answers_by_submission_id(conn, answer.submission_id)
614                .await?;
615        let new_answer = AnswerRequiringAttentionWithTasks {
616            id: answer.id,
617            user_id: answer.user_id,
618            created_at: answer.created_at,
619            updated_at: answer.updated_at,
620            deleted_at: answer.deleted_at,
621            data_json: answer.data_json.to_owned(),
622            grading_progress: answer.grading_progress,
623            score_given: answer.score_given,
624            submission_id: answer.submission_id,
625            exercise_id: answer.exercise_id,
626            tasks,
627            given_peer_reviews,
628            received_peer_or_self_reviews,
629            received_peer_review_flagging_reports,
630        };
631        answers.push(new_answer);
632    }
633    Ok(AnswersRequiringAttention {
634        exercise_max_points: exercise.score_maximum,
635        data: answers,
636        total_pages: pagination.total_pages(answer_requiring_attention_count),
637    })
638}