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 assignment(&self) -> u64 {
68        self.assignment_id
69    }
70
71    pub fn attempt(&self) -> u64 {
72        self.attempt.unwrap_or(0)
73    }
74
75    pub fn user(&self) -> u64 {
76        self.user_id
77    }
78
79    pub fn files(&self) -> Option<Vec<FileSubmission>> {
80        Some(
81            self.attachments
82                .clone()?
83                .into_iter()
84                .map(|f| FileSubmission::new(self, f))
85                .collect(),
86        )
87    }
88
89    pub async fn assignment_submissions(
90        assignment_id: u64,
91        predicate: &dyn Fn(&Self) -> bool,
92        config: &Config,
93    ) -> Result<Vec<Self>> {
94        let url = format!(
95            "{}/api/v1/courses/{}/assignments/{assignment_id}/submissions",
96            config.base_url, config.course_id
97        );
98
99        let mut next_page_exists = true;
100        let mut page = 1;
101        let mut responses: Vec<Self> = Vec::new();
102        while next_page_exists {
103            let page_str = page.to_string();
104            let form = HashMap::from([
105                ("workflow_state", "submitted"),
106                // ("per_page", "50"),
107                ("page", &page_str),
108            ]);
109
110            info!("Requesting from \"{url}\", page {page}");
111            let response = config.client.get(&url).query(&form).send().await?;
112            let headers = response.headers().clone();
113
114            info!("Getting body from response...");
115            let body = response.text().await?;
116            let untyped: serde_json::Value =
117                serde_json::from_str(&body).context("Failed to parse invalid JSON body.")?;
118            info!("Parsed into untyped JSON");
119
120            info!("Attempting to parse JSON into structured data type...");
121
122            let mut structured = serde_json::from_str(&body).with_context(|| {
123                format!("Unable to parse response to data type: {:#?}", untyped)
124            })?;
125            responses.append(&mut structured);
126
127            next_page_exists = headers
128                .get("Link")
129                .context("Failed to get link header.")?
130                .to_str()
131                .context("Failed to stringify link header")?
132                .contains("next");
133            page += 1;
134        }
135
136        let mut res: Vec<_> = responses
137            .into_iter()
138            .sorted_unstable_by_key(|r| r.user_id)
139            .sorted_by_key(|r| -(r.attempt() as i64))
140            .filter(predicate)
141            .collect();
142        res.dedup_by_key(|r| r.user_id);
143
144        Ok(res)
145    }
146
147    pub async fn count_submissions(
148        assignment_id: u64,
149        predicate: &dyn Fn(&Self) -> bool,
150        config: &Config,
151    ) -> Result<usize> {
152        let url = format!(
153            "{}/api/v1/courses/{}/assignments/{assignment_id}/submissions",
154            config.base_url, config.course_id
155        );
156
157        let mut next_page_exists = true;
158        let mut page = 1;
159        let mut responses: Vec<Self> = Vec::new();
160        while next_page_exists {
161            let page_str = page.to_string();
162            let form = HashMap::from([
163                ("workflow_state", "submitted"),
164                // ("per_page", "50"),
165                ("page", &page_str),
166            ]);
167
168            info!("Requesting from \"{url}\", page {page}");
169            let response = config.client.get(&url).query(&form).send().await?;
170            let headers = response.headers().clone();
171
172            info!("Getting body from response...");
173            let body = response.text().await?;
174            let untyped: serde_json::Value =
175                serde_json::from_str(&body).context("Failed to parse invalid JSON body.")?;
176            info!("Parsed into untyped JSON");
177
178            info!("Attempting to parse JSON into structured data type...");
179
180            let mut structured = serde_json::from_str(&body).with_context(|| {
181                format!("Unable to parse response to data type: {:#?}", untyped)
182            })?;
183            responses.append(&mut structured);
184
185            next_page_exists = headers
186                .get("Link")
187                .context("Failed to get link header.")?
188                .to_str()
189                .context("Failed to stringify link header")?
190                .contains("next");
191            page += 1;
192        }
193
194        Ok(responses.into_iter().filter(predicate).count())
195    }
196
197    pub async fn update_grades(
198        assignment_id: u64,
199        grades: &[Grade],
200        config: &Config,
201    ) -> Result<()> {
202        let form = HashMap::<String, f32>::from_iter(
203            grades
204                .iter()
205                .map(|g| (format!("grade_data[{}][posted_grade]", g.user_id), g.grade)),
206        );
207
208        config
209            .client
210            .post(format!(
211                "{}/api/v1/courses/{}/assignments/{assignment_id}/submissions/update_grades",
212                config.base_url, config.course_id
213            ))
214            .form(&form)
215            .send()
216            .await?;
217
218        Ok(())
219    }
220
221    pub async fn update_grades_with_comments(
222        assignment_id: u64,
223        grades: &[Grade],
224        comments: &[Comment],
225        config: &Config,
226    ) -> Result<()> {
227        let form_grades = grades.iter().map(|g| {
228            (
229                format!("grade_data[{}][posted_grade]", g.user_id),
230                g.grade.to_string(),
231            )
232        });
233        let form_comments = comments.iter().map(|c| {
234            (
235                format!("grade_data[{}][text_comment]", c.user_id),
236                c.comment.to_owned(),
237            )
238        });
239
240        let form = HashMap::<String, String>::from_iter(form_grades.chain(form_comments));
241
242        config
243            .client
244            .post(format!(
245                "{}/api/v1/courses/{}/assignments/{assignment_id}/submissions/update_grades",
246                config.base_url, config.course_id
247            ))
248            .form(&form)
249            .send()
250            .await?;
251
252        Ok(())
253    }
254}