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