canvas_grading/
submission.rs

1use 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    /// None if submission has not been graded
18    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                // ("per_page", "50"),
96                ("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                // ("per_page", "50"),
146                ("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}