use anyhow::{bail, Context, Result}; use chrono::Datelike; use cookie::Cookie; use serde::Deserialize; use serde_json::json; use url::Url; fn deserialize_date<'de, D>(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { struct Visitor; impl<'de> serde::de::Visitor<'de> for Visitor { type Value = chrono::NaiveDate; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a naive date serialized as an unsigned integer") } fn visit_u64(self, mut v: u64) -> Result where E: serde::de::Error, { let y = (v / 10000) as i32; v %= 10000; let m = (v / 100) as u32; v %= 100; match chrono::NaiveDate::from_ymd_opt(y, m, v as u32) { Some(x) => Ok(x), None => Err(E::custom(format!("No such date: {}-{}-{}", y, m, v))), } } } deserializer.deserialize_u64(Visitor) } fn serialize_date(date: chrono::NaiveDate) -> u64 { date.year() as u64 * 10000 + date.month() as u64 * 100 + date.day() as u64 } fn deserialize_time<'de, D>(deserializer: D) -> Result where D: serde::de::Deserializer<'de>, { struct Visitor; impl<'de> serde::de::Visitor<'de> for Visitor { type Value = chrono::NaiveTime; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a naive time serialized as an unsigned integer") } fn visit_u64(self, mut v: u64) -> Result where E: serde::de::Error, { let h = v / 100; v %= 100; match chrono::NaiveTime::from_hms_opt(h as u32, v as u32, 0) { Some(x) => Ok(x), None => Err(E::custom(format!("No such time: {}:{}", h, v))), } } } deserializer.deserialize_u64(Visitor) } #[derive(Deserialize, Debug)] pub struct RpcError { pub message: String, pub code: i32, } #[derive(Deserialize, Debug)] pub struct RpcResponse { pub jsonrpc: String, pub id: String, pub result: Option, pub error: Option, } #[derive(Deserialize, Debug)] struct RpcEmptyResult; #[derive(Deserialize, Debug)] pub struct RpcLogin { #[serde(rename = "sessionId")] pub session_id: String, #[serde(rename = "personType")] pub person_type: i32, #[serde(rename = "personId")] pub person_id: i32, #[serde(rename = "klasseId")] pub class_id: i32, } #[derive(Deserialize, Debug)] pub struct RpcClass { pub id: i32, pub name: String, #[serde(rename = "longName")] pub long_name: String, pub active: bool, } #[derive(Deserialize, Debug)] pub struct RpcSubject { pub id: i32, pub name: String, #[serde(rename = "longName")] pub long_name: String, } #[derive(Deserialize, Debug)] pub struct RpcRoom { pub id: i32, pub name: String, #[serde(rename = "longName")] pub long_name: String, } #[derive(Deserialize, Debug)] pub struct RpcDepartment { pub id: i32, pub name: String, #[serde(rename = "longName")] pub long_name: String, } #[derive(Deserialize, Debug)] pub struct RpcHoliday { pub id: i32, pub name: String, #[serde(rename = "longName")] pub long_name: String, #[serde(rename = "startDate", deserialize_with = "deserialize_date")] pub start_date: chrono::NaiveDate, #[serde(rename = "endDate", deserialize_with = "deserialize_date")] pub end_date: chrono::NaiveDate, } #[derive(Deserialize, Debug)] pub struct RpcTimegridDayTimeUnit { #[serde(rename = "startTime", deserialize_with = "deserialize_time")] pub start_time: chrono::NaiveTime, #[serde(rename = "endTime", deserialize_with = "deserialize_time")] pub end_time: chrono::NaiveTime, } #[derive(Deserialize, Debug)] pub struct RpcTimegridDay { pub day: u8, #[serde(rename = "timeUnits")] pub time_units: Vec, } #[derive(Deserialize, Debug)] pub struct RpcSchoolyear { pub id: i32, pub name: String, #[serde(rename = "startDate", deserialize_with = "deserialize_date")] pub start_date: chrono::NaiveDate, #[serde(rename = "endDate", deserialize_with = "deserialize_date")] pub end_date: chrono::NaiveDate, } #[derive(Deserialize, Clone, Copy, Debug)] pub enum RpcSubstitionType { #[serde(rename = "cancel")] Cancel, #[serde(rename = "subst")] Substitution, #[serde(rename = "add")] Additional, #[serde(rename = "shift")] Shifted, #[serde(rename = "rmchg")] RoomChange, #[serde(rename = "rmlk")] Locked, #[serde(rename = "bs")] BreakSupervision, #[serde(rename = "oh")] OfficeHour, #[serde(rename = "sb")] Standby, #[serde(rename = "other")] Other, #[serde(rename = "free")] Free, #[serde(rename = "exam")] Exam, #[serde(rename = "ac")] Activity, #[serde(rename = "holi")] Holiday, #[serde(rename = "stxt")] Text, } #[derive(Deserialize, Debug)] pub struct RpcSubstitutionId { pub id: i32, pub name: Option, #[serde(rename = "externalkey")] pub external_key: Option, #[serde(rename = "orgid")] pub original_id: Option, #[serde(rename = "orgname")] pub original_name: Option, #[serde(rename = "orgexternalkey")] pub original_external_key: Option, } #[derive(Deserialize, Debug)] pub struct RpcSubstitutionReschedule { #[serde(deserialize_with = "deserialize_date")] pub date: chrono::NaiveDate, #[serde(rename = "startTime", deserialize_with = "deserialize_time")] pub start_time: chrono::NaiveTime, #[serde(rename = "endTime", deserialize_with = "deserialize_time")] pub end_time: chrono::NaiveTime, } #[derive(Deserialize, Debug)] pub struct RpcSubstitution { #[serde(rename = "type")] pub subst_type: RpcSubstitionType, #[serde(rename = "lsid")] pub lesson_id: i32, #[serde(rename = "startTime", deserialize_with = "deserialize_time")] pub start_time: chrono::NaiveTime, #[serde(rename = "endTime", deserialize_with = "deserialize_time")] pub end_time: chrono::NaiveTime, #[serde(rename = "txt")] pub text: Option, #[serde(rename = "kl")] pub classes: Vec, #[serde(rename = "te")] pub teachers: Vec, #[serde(rename = "su")] pub subjects: Vec, #[serde(rename = "ro")] pub rooms: Vec, pub reschedule: Option, } #[derive(Deserialize, Debug)] pub struct ApiTenant { pub id: i32, #[serde(rename = "displayName")] pub display_name: String, } #[derive(Deserialize, Debug)] struct ApiDataResponse { tenant: ApiTenant, } #[derive(Deserialize, Debug)] pub struct ApiTeacher { pub id: i32, pub name: String, pub forename: String, #[serde(rename = "longName")] pub long_name: String, #[serde(rename = "displayname")] pub display_name: String, } #[derive(Deserialize, Debug)] struct ApiTeachersResponseData { elements: Vec, } #[derive(Deserialize, Debug)] struct ApiTeachersResponse { data: ApiTeachersResponseData, } #[derive(Debug)] pub struct Client { pub webuntis_url: Url, pub rpc_url: Url, pub client_name: String, pub user_agent: String, pub username: String, pub password: String, pub session: Option, pub authorization: Option, } impl Client { pub fn gen_rpc_url(endpoint: &Url, school: &str) -> Result { let mut x = endpoint.join("jsonrpc.do")?; x.query_pairs_mut().append_pair("school", school); Ok(x) } pub async fn login_rpc(&mut self) -> Result { let resp: RpcResponse = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .json(&json!({ "id": "ID", "method": "authenticate", "params": { "user": self.username, "password": self.password, "client": self.client_name, }, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } self.session = Some( Cookie::new( "JSESSIONID", &resp .result .as_ref() .context("Result null event though error not")? .session_id, ) .to_string(), ); if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub async fn login_api(&mut self) -> Result { let jwt = reqwest::Client::new() .get(self.webuntis_url.join("api/token/new")?) .header(reqwest::header::USER_AGENT, &self.user_agent) .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .send() .await? .text() .await?; self.authorization = Some(jwt.to_string()); Ok(jwt) } pub async fn login(&mut self) -> Result<()> { self.login_rpc().await?; self.login_api().await?; Ok(()) } pub async fn logout(&mut self) -> Result<()> { let resp: RpcResponse = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "logout", "params": {}, "jsonrpc": "2.0" })) .send() .await? .json() .await?; self.session = None; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } Ok(()) } pub async fn classes(&self) -> Result> { let resp: RpcResponse> = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "getKlassen", "params": {}, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub async fn subjects(&self) -> Result> { let resp: RpcResponse> = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "getSubjects", "params": {}, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub async fn rooms(&self) -> Result> { let resp: RpcResponse> = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "getRooms", "params": {}, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub async fn departments(&self) -> Result> { let resp: RpcResponse> = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "getDepartments", "params": {}, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub async fn holidays(&self) -> Result> { let resp: RpcResponse> = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "getHolidays", "params": {}, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub async fn timegrid(&self) -> Result> { let resp: RpcResponse> = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "getTimegridUnits", "params": {}, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub async fn current_schoolyear(&self) -> Result { let resp: RpcResponse = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "getCurrentSchoolyear", "params": {}, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub async fn schoolyears(&self) -> Result> { let resp: RpcResponse> = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "getSchoolyears", "params": {}, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub async fn substitutions( &self, from: &chrono::NaiveDate, to: &chrono::NaiveDate, department: Option, ) -> Result> { let resp: RpcResponse> = reqwest::Client::new() .get(self.rpc_url.as_str()) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .json(&json!({ "id": "ID", "method": "getSubstitutions", "params": { "startDate": serialize_date(*from), "endDate": serialize_date(*to), "departmentId": department.map_or(0, |d| d) }, "jsonrpc": "2.0" })) .send() .await? .json() .await?; if let Some(e) = resp.error { bail!("RPC error: {:?}", e); } if let Some(x) = resp.result { Ok(x) } else { bail!("RPC result is null"); } } pub fn construct_bearer(auth: &str) -> String { format!("Bearer {}", auth) } pub async fn current_tenant(&self) -> Result { let resp: ApiDataResponse = reqwest::Client::new() .get(self.webuntis_url.join("api/rest/view/v1/app/data")?) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .header( reqwest::header::AUTHORIZATION, Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?), ) .send() .await? .json() .await?; Ok(resp.tenant) } pub async fn teachers(&self) -> Result> { let mut url = self .webuntis_url .join("api/public/timetable/weekly/pageconfig")?; url.query_pairs_mut().append_pair("type", "2"); let resp: ApiTeachersResponse = reqwest::Client::new() .get(url) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .header( reqwest::header::AUTHORIZATION, Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?), ) .send() .await? .json() .await?; Ok(resp.data.elements) } pub async fn student_reports(&self, class_id: i32) -> Result<()> { #[derive(Deserialize, Debug)] struct Data { finished: bool, error: bool, #[serde(rename = "messageId")] message_id: i32, #[serde(rename = "reportParams")] report_params: String, } #[derive(Deserialize, Debug)] struct Response { data: Data, } let mut url = self.webuntis_url.join("reports.do")?; url.query_pairs_mut() .append_pair("name", "Student") .append_pair("format", "csv") .append_pair("klasseId", &class_id.to_string()) .append_pair("studentsForDate", "true"); let resp: Response = reqwest::Client::new() .get(url) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::ACCEPT, "application/json") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .header( reqwest::header::AUTHORIZATION, Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?), ) .send() .await? .json() .await?; dbg!(&resp); if resp.data.error { bail!("Error generating report"); } else if !resp.data.finished { bail!("Report not finished"); } let mut records_url = self.webuntis_url.join("reports.do")?; records_url.set_query(Some(&resp.data.report_params)); dbg!(&records_url); records_url .query_pairs_mut() .append_pair("msgId", &resp.data.message_id.to_string()); dbg!(&records_url); let records = reqwest::Client::new() .get(records_url) .header(reqwest::header::USER_AGENT, &self.user_agent) .header(reqwest::header::ACCEPT, "text/csv") .header( reqwest::header::COOKIE, self.session.as_ref().context("Not logged in")?, ) .header( reqwest::header::AUTHORIZATION, Client::construct_bearer(&self.authorization.as_ref().context("Not logged in")?), ) .send() .await? .text() .await?; let mut reader = csv::ReaderBuilder::new() .delimiter(b'\t') .from_reader(records.as_bytes()); for record in reader.records() { let record = record?; dbg!(record); } Ok(()) } }