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    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                // ("per_page", "50"),
94                ("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                // ("per_page", "50"),
144                ("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}