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) || !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                // ("per_page", "50"),
95                ("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                // ("per_page", "50"),
145                ("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}