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 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 ("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 ("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}