canvas_grading/
submission.rs1use 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 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 ("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 ("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}