canvas_grading/
submission.rs

1use std::collections::HashMap;
2
3use anyhow::{Context, Result};
4use itertools::Itertools;
5use serde::Deserialize;
6use tracing::info;
7
8use crate::{
9    file::{CanvasFile, FileSubmission},
10    Comment, Config, Grade,
11};
12
13#[derive(Debug, Deserialize)]
14pub struct Submission {
15    user_id: u64,
16    assignment_id: u64,
17    attempt: Option<u64>,
18    /// None if submission has not been graded
19    grader_id: Option<u64>,
20    score: Option<f32>,
21    workflow_state: WorkflowState,
22    redo_request: bool,
23    attachments: Option<Vec<CanvasFile>>,
24}
25
26#[derive(Debug, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum WorkflowState {
29    Unsubmitted,
30    Submitted,
31    Graded,
32    PendingReview,
33}
34
35impl std::fmt::Display for Submission {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(
38            f,
39            "{}_{}_{}",
40            self.user_id,
41            self.assignment_id,
42            self.attempt()
43        )
44    }
45}
46
47impl Submission {
48    pub fn graded(&self) -> bool {
49        !self.redo_request
50            && self.grader_id.is_some()
51            && self.score.is_some()
52            && matches!(self.workflow_state, WorkflowState::Graded)
53    }
54
55    pub fn submitted(&self) -> bool {
56        !self.unsubmitted()
57    }
58
59    pub fn ungraded(&self) -> bool {
60        self.submitted() && !self.graded()
61    }
62
63    pub fn unsubmitted(&self) -> bool {
64        matches!(self.workflow_state, WorkflowState::Unsubmitted) || self.attachments.is_none()
65    }
66
67    pub fn grade_100(&self) -> bool {
68        self.submitted() && self.graded() && self.score.unwrap() as usize == 100
69    }
70
71    pub fn grade_not_100(&self) -> bool {
72        self.submitted() && !self.grade_100()
73    }
74
75    pub fn assignment(&self) -> u64 {
76        self.assignment_id
77    }
78
79    pub fn attempt(&self) -> u64 {
80        self.attempt.unwrap_or(0)
81    }
82
83    pub fn user(&self) -> u64 {
84        self.user_id
85    }
86
87    pub fn files(&self) -> Option<Vec<FileSubmission>> {
88        Some(
89            self.attachments
90                .clone()?
91                .into_iter()
92                .map(|f| FileSubmission::new(self, f))
93                .collect(),
94        )
95    }
96
97    pub async fn assignment_submissions(
98        assignment_id: u64,
99        predicate: &dyn Fn(&Self) -> bool,
100        config: &Config,
101    ) -> Result<Vec<Self>> {
102        let url = format!(
103            "{}/api/v1/courses/{}/assignments/{assignment_id}/submissions",
104            config.base_url, config.course_id
105        );
106
107        let mut next_page_exists = true;
108        let mut page = 1;
109        let mut responses: Vec<Self> = Vec::new();
110        while next_page_exists {
111            let page_str = page.to_string();
112            let form = HashMap::from([
113                ("workflow_state", "submitted"),
114                // ("per_page", "50"),
115                ("page", &page_str),
116            ]);
117
118            info!("Requesting from \"{url}\", page {page}");
119            let response = config.client.get(&url).query(&form).send().await?;
120            let headers = response.headers().clone();
121
122            info!("Getting body from response...");
123            let body = response.text().await?;
124            let untyped: serde_json::Value =
125                serde_json::from_str(&body).context("Failed to parse invalid JSON body.")?;
126            info!("Parsed into untyped JSON");
127
128            info!("Attempting to parse JSON into structured data type...");
129
130            let mut structured = serde_json::from_str(&body).with_context(|| {
131                format!("Unable to parse response to data type: {:#?}", untyped)
132            })?;
133            responses.append(&mut structured);
134
135            next_page_exists = headers
136                .get("Link")
137                .context("Failed to get link header.")?
138                .to_str()
139                .context("Failed to stringify link header")?
140                .contains("next");
141            page += 1;
142        }
143
144        let mut res: Vec<_> = responses
145            .into_iter()
146            .sorted_unstable_by_key(|r| r.user_id)
147            .sorted_by_key(|r| -(r.attempt() as i64))
148            .filter(predicate)
149            .collect();
150        res.dedup_by_key(|r| r.user_id);
151
152        Ok(res)
153    }
154
155    pub async fn count_submissions(
156        assignment_id: u64,
157        predicate: &dyn Fn(&Self) -> bool,
158        config: &Config,
159    ) -> Result<usize> {
160        let url = format!(
161            "{}/api/v1/courses/{}/assignments/{assignment_id}/submissions",
162            config.base_url, config.course_id
163        );
164
165        let mut next_page_exists = true;
166        let mut page = 1;
167        let mut responses: Vec<Self> = Vec::new();
168        while next_page_exists {
169            let page_str = page.to_string();
170            let form = HashMap::from([
171                ("workflow_state", "submitted"),
172                // ("per_page", "50"),
173                ("page", &page_str),
174            ]);
175
176            info!("Requesting from \"{url}\", page {page}");
177            let response = config.client.get(&url).query(&form).send().await?;
178            let headers = response.headers().clone();
179
180            info!("Getting body from response...");
181            let body = response.text().await?;
182            let untyped: serde_json::Value =
183                serde_json::from_str(&body).context("Failed to parse invalid JSON body.")?;
184            info!("Parsed into untyped JSON");
185
186            info!("Attempting to parse JSON into structured data type...");
187
188            let mut structured = serde_json::from_str(&body).with_context(|| {
189                format!("Unable to parse response to data type: {:#?}", untyped)
190            })?;
191            responses.append(&mut structured);
192
193            next_page_exists = headers
194                .get("Link")
195                .context("Failed to get link header.")?
196                .to_str()
197                .context("Failed to stringify link header")?
198                .contains("next");
199            page += 1;
200        }
201
202        Ok(responses.into_iter().filter(predicate).count())
203    }
204
205    pub async fn update_grades(
206        assignment_id: u64,
207        grades: &[Grade],
208        config: &Config,
209    ) -> Result<()> {
210        let form = HashMap::<String, f32>::from_iter(
211            grades
212                .iter()
213                .map(|g| (format!("grade_data[{}][posted_grade]", g.user_id), g.grade)),
214        );
215
216        config
217            .client
218            .post(format!(
219                "{}/api/v1/courses/{}/assignments/{assignment_id}/submissions/update_grades",
220                config.base_url, config.course_id
221            ))
222            .form(&form)
223            .send()
224            .await?;
225
226        Ok(())
227    }
228
229    pub async fn update_grades_with_comments(
230        assignment_id: u64,
231        grades: &[Grade],
232        comments: &[Comment],
233        config: &Config,
234    ) -> Result<()> {
235        let form_grades = grades.iter().map(|g| {
236            (
237                format!("grade_data[{}][posted_grade]", g.user_id),
238                g.grade.to_string(),
239            )
240        });
241        let form_comments = comments.iter().map(|c| {
242            (
243                format!("grade_data[{}][text_comment]", c.user_id),
244                c.comment.to_owned(),
245            )
246        });
247
248        let form = HashMap::<String, String>::from_iter(form_grades.chain(form_comments));
249
250        config
251            .client
252            .post(format!(
253                "{}/api/v1/courses/{}/assignments/{assignment_id}/submissions/update_grades",
254                config.base_url, config.course_id
255            ))
256            .form(&form)
257            .send()
258            .await?;
259
260        Ok(())
261    }
262}