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