use std::{collections::HashMap, fmt::Display};
use crate::{Config, NonEmptyConfig};
use anyhow::anyhow;
use canvas_cli::{Course, DateTime};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use inquire::Select;
use regex::Regex;
use reqwest::{multipart::Form, Client};
use serde_derive::Deserialize;
#[derive(Debug)]
struct Assignment {
id: u32,
name: String,
due_at: Option<DateTime>,
is_graded: bool,
}
impl Display for Assignment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.name, if self.is_graded { " ✓" } else { "" })
}
}
#[derive(Deserialize, Debug)]
struct AssignmentResponse {
id: u32,
name: String,
due_at: Option<DateTime>,
locked_for_user: bool,
graded_submissions_exist: bool,
submission_types: Vec<String>,
}
#[derive(Deserialize, Debug)]
struct UploadBucket {
upload_url: String,
upload_params: HashMap<String, String>,
}
#[derive(Deserialize, Debug)]
struct UploadResponse {
id: u32,
display_name: Option<String>,
}
#[derive(clap::Parser, Debug)]
pub struct SubmitCommand {
files: Vec<String>,
#[clap(long, short)]
url: Option<String>,
#[clap(long, short)]
course: Option<u32>,
#[clap(long, short)]
assignment: Option<u32>,
}
impl SubmitCommand {
pub async fn action(&self, cfg: &Config) -> Result<(), anyhow::Error> {
let NonEmptyConfig {
url: mut base_url,
access_token,
} = cfg.ensure_non_empty()?;
if self.files.is_empty() {
Err(anyhow!("Must submit at least one file"))?;
}
for file in self.files.iter() {
match std::fs::metadata(file) {
Ok(_) => Ok(()),
Err(error) => Err(anyhow!("{}: {}", error, file)),
}?;
log::info!("Verified file exists: {}", file);
}
println!("✓ Verified all files exist");
let client = reqwest::Client::builder()
.default_headers(
std::iter::once((
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", access_token))
.unwrap(),
))
.collect(),
)
.build()
.unwrap();
let mut course_id = self.course;
let mut assignment_id = self.assignment;
let canvas_assignment_url = if let Ok(env_canvas_url) = std::env::var("CANVAS_URL") {
Some(env_canvas_url)
} else {
self.url.clone()
};
if let Some(canvas_assignment_url) = canvas_assignment_url {
let regex = Regex::new(r#"(https://.+)/courses/(\d+)(?:/assignments/(\d+))?"#).unwrap();
let captures = regex.captures(&canvas_assignment_url).unwrap();
base_url = captures.get(1).unwrap().as_str().to_string();
course_id = Some(captures.get(2).unwrap().as_str().parse::<u32>().unwrap());
if let Some(a_id) = captures.get(3) {
assignment_id = Some(a_id.as_str().parse::<u32>().unwrap());
}
}
if let Ok(env_canvas_course_id) = std::env::var("CANVAS_COURSE_ID") {
course_id = Some(env_canvas_course_id.parse::<u32>().unwrap())
}
if let Ok(env_canvas_assignment_id) = std::env::var("CANVAS_ASSIGNMENT_ID") {
assignment_id = Some(env_canvas_assignment_id.parse::<u32>().unwrap())
}
let base_url = base_url;
let course_id = course_id;
let assignment_id = assignment_id;
let course = Course::fetch(course_id, &base_url, &client).await?;
log::info!("Selected course {}", course.id);
let assignment = if let Some(assignment_id) = assignment_id {
let assignment_response = client
.get(format!(
"{}/api/v1/courses/{}/assignments/{}",
base_url, course.id, assignment_id
))
.send()
.await?
.json::<AssignmentResponse>()
.await?;
log::info!("Made REST request to get assignment information");
let assignment = Assignment {
name: assignment_response.name,
id: assignment_response.id,
due_at: assignment_response.due_at,
is_graded: assignment_response.graded_submissions_exist,
};
println!("✓ Found {assignment}");
assignment
} else {
let mut assignments: Vec<Assignment> = client
.get(format!(
"{}/api/v1/courses/{}/assignments?per_page=1000",
base_url, course.id
))
.send()
.await?
.json::<Vec<AssignmentResponse>>()
.await?
.into_iter()
.filter(|assignment| {
!assignment.locked_for_user && assignment.submission_types[0] == "online_upload"
})
.map(|assignment| Assignment {
name: assignment.name,
id: assignment.id,
due_at: assignment.due_at,
is_graded: assignment.graded_submissions_exist,
})
.collect();
log::info!("Made REST request to get assignment information");
println!("✓ Queried assignment information");
assignments.sort_by(|a, b| a.is_graded.cmp(&b.is_graded).then(a.due_at.cmp(&b.due_at)));
Select::new("Assignment?", assignments).prompt()?
};
log::info!("Selected assignment {}", assignment.id);
let multi_progress = MultiProgress::new();
let future_files = self.files.iter().map(|filepath| {
upload_file(
&base_url,
&course,
&assignment,
&client,
filepath,
&multi_progress,
)
});
let uploaded_files = futures::future::join_all(future_files).await;
let mut params: Vec<(&'static str, String)> = uploaded_files
.into_iter()
.map(|f| ("submission[file_ids][]", f.unwrap().id.to_string()))
.collect();
params.push(("submission[submission_type]", "online_upload".to_string()));
let submit_reponse = client
.post(format!(
"{}/api/v1/courses/{}/assignments/{}/submissions",
base_url, course.id, assignment.id
))
.query(¶ms)
.send()
.await?;
submit_reponse.error_for_status()?;
println!(
"✓ Successfully submitted file{} to assignment 🎉",
if self.files.len() > 1 { "s" } else { "" }
);
Ok(())
}
}
async fn upload_file(
url: &str,
course: &Course,
assignment: &Assignment,
client: &Client,
filepath: &str,
multi_progress: &MultiProgress,
) -> Result<UploadResponse, anyhow::Error> {
let metadata = std::fs::metadata(filepath).unwrap();
let path = std::path::Path::new(filepath);
let basename = path.file_name().unwrap().to_str().unwrap();
let spinner = multi_progress.add(ProgressBar::new_spinner());
spinner.set_message(format!("Uploading file {} as {}", filepath, basename));
let spinner_clone = spinner.clone();
let spinner_task = tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
spinner_clone.inc(1);
}
});
let upload_bucket = client
.post(format!(
"{}/api/v1/courses/{}/assignments/{}/submissions/self/files",
url, course.id, assignment.id
))
.form(&HashMap::from([
("name", basename),
("size", metadata.len().to_string().as_str()),
]))
.send()
.await?
.json::<UploadBucket>()
.await
.unwrap();
spinner.set_message(format!(
"Uploading {}: recieved upload bucket, sending file payload",
filepath
));
let location = Client::new()
.post(upload_bucket.upload_url)
.multipart(
upload_bucket
.upload_params
.into_iter()
.fold(Form::new(), |form, (k, v)| form.text(k, v))
.file("file", path)
.await?,
)
.send()
.await?
.headers()
.get("Location")
.unwrap()
.to_str()
.unwrap()
.to_owned();
spinner.set_message(format!(
"Uploading {}: recieved upload location, checking response",
filepath
));
let upload_response = client
.post(location)
.header("Content-Length", 0)
.send()
.await?
.json::<UploadResponse>()
.await
.unwrap();
spinner_task.abort();
spinner.set_style(ProgressStyle::with_template("✓ {wide_msg}").unwrap());
match &upload_response.display_name {
Some(display_name) => {
spinner.finish_with_message(format!("Uploaded file {} as {}", filepath, display_name))
}
None => spinner.finish_with_message(format!("Uploaded file {}", filepath)),
}
Ok(upload_response)
}