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