use anyhow::{Context, Result, anyhow}; use url::Url; use serde::{Serialize, Deserialize}; use fully_pub::fully_pub; use crate::utils::Id; use chrono::prelude::{DateTime, Utc}; use chrono::NaiveDate; use thiserror::Error; use crate::sync_paheko::GeneralizedAnswer; use crate::utils::deserialize_date; #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct HelloassoReferences { answer_id: u64, order_id: u64 // payment_id: u64, } #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct ExternalReferences { helloasso_refs: HelloassoReferences } /// for now we include the custom fields into the paheko user /// we don't have time to implement user settings to change the custom fields mapping /// for now, manual mapping #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct User { id: Id, first_name: Option<String>, last_name: String, email: Option<String>, phone: Option<String>, address: String, city: String, postal_code: String, country: String, skills: Option<String>, job: Option<String>, birth_year: Option<u32>, register_time: DateTime<Utc> } #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct UserSummary { id: Id, first_name: Option<String>, last_name: String, email: Option<String> } #[derive(Debug, Serialize, Deserialize, Clone)] #[fully_pub] struct Membership { id: Id, users_ids: Vec<Id>, service_name: String, mode_name: String, start_time: DateTime<Utc>, end_time: DateTime<Utc>, payed_amount: f64 } #[derive(Debug, Clone)] #[fully_pub] enum TransactionKind { Expense, Revenue } impl From<TransactionKind> for String { fn from(val: TransactionKind) -> Self { match val { TransactionKind::Expense => "EXPENSE".to_string(), TransactionKind::Revenue => "REVENUE".to_string() } } } #[derive(Debug, Clone)] #[fully_pub] struct SimpleTransaction { label: String, kind: TransactionKind, inception_time: DateTime<Utc>, amount: f64, credit_account_code: String, debit_account_code: String, reference: String, linked_users: Vec<Id>, linked_services: Vec<Id>, accounting_year: Id } #[derive(Debug, Clone, Deserialize)] #[fully_pub] struct AccountingYear { id: Id, label: String, closed: u32, #[serde(deserialize_with = "deserialize_date", rename="start_date")] start_date: NaiveDate, #[serde(deserialize_with = "deserialize_date", rename="end_date")] end_date: NaiveDate } #[derive(Error, Debug)] enum APIClientError { #[error("Received non-normal status code from API")] InvalidStatusCode } #[derive(Debug, Clone)] #[fully_pub] struct Credentials { client_id: String, client_secret: String } #[derive(Debug, Clone)] #[fully_pub] struct ClientConfig { base_url: Url, proxy: Option<reqwest::Proxy>, user_agent: String } impl Default for ClientConfig { fn default() -> Self { ClientConfig { proxy: None, base_url: Url::parse("https://paheko.example.org/api/") // the traling slash is important .expect("Expected valid paheko API base URL"), user_agent: "".to_string() } } } #[derive(Debug)] #[fully_pub] struct Client { client: reqwest::Client, config: ClientConfig } impl Default for Client { fn default() -> Self { let base_config: ClientConfig = Default::default(); Client { client: Client::get_base_client_builder(&base_config) .build() .expect("Expected reqwest client to be built"), config: base_config } } } use base64_light::base64_encode; fn build_auth_headers(credentials: &Credentials) -> reqwest::header::HeaderMap { let mut login_headers = reqwest::header::HeaderMap::new(); login_headers.insert( "Authorization", format!("Basic {}", &base64_encode( &format!("{}:{}", &credentials.client_id, &credentials.client_secret) )).parse().expect("Header value to be OK") ); login_headers } impl Client { pub fn new(config: ClientConfig) -> Client { Client { client: Client::get_base_client_builder(&config) .build() .expect("Expected reqwest client to be built"), config } } fn get_base_client_builder(config: &ClientConfig) -> reqwest::ClientBuilder { let mut default_headers = reqwest::header::HeaderMap::new(); default_headers.insert("Accept", "application/json".parse().unwrap()); default_headers.insert("User-Agent", config.user_agent.parse().unwrap()); let mut builder = reqwest::Client::builder() .default_headers(default_headers); if let Some(proxy) = &config.proxy { builder = builder.proxy(proxy.clone()); } builder } pub async fn login(&mut self, credentials: Credentials) -> Result<AuthentifiedClient> { let hypothetic_client = self.authentified_client(credentials); let query: String = r#" SELECT key,value FROM config WHERE key="org_name" "#.to_string(); match hypothetic_client.sql_query(query).await { Ok(_value) => { Ok(hypothetic_client) }, Err(err) => { Err(anyhow!("Failed to authenticate: Credentials provided are invalids, {:?}", err)) } } } pub fn authentified_client(&self, credentials: Credentials) -> AuthentifiedClient { AuthentifiedClient::new(self.config.clone(), credentials) } } #[derive(Debug, Clone)] pub struct AuthentifiedClient { _credentials: Credentials, client: reqwest::Client, config: ClientConfig } // SELECT id,nom AS first_name,last_name,email,external_custom_data FROM users LIMIT 5; #[derive(Debug, Deserialize)] #[fully_pub] struct SimpleUser { id: u32, first_name: String, last_name: String, email: Option<String>, external_custom_data: Option<String> } #[derive(Debug, Deserialize)] #[fully_pub] struct SqlQueryOutput { count: u64, results: serde_json::Value } #[derive(Debug, Deserialize)] #[fully_pub] struct TransactionSummary { id: u64, reference: String } #[derive(Debug)] #[fully_pub] struct UserServiceRegistration { id: Id } impl AuthentifiedClient { pub fn new(config: ClientConfig, credentials: Credentials) -> Self { AuthentifiedClient { client: Client::get_base_client_builder(&config) .default_headers(build_auth_headers(&credentials)) .build() .expect("Expect client to be built"), _credentials: credentials, config } } pub async fn sql_query(&self, query: String) -> Result<SqlQueryOutput> { #[derive(Serialize)] struct Payload { sql: String } let payload = Payload { sql: query }; let path = self.config.base_url.join("sql")?; let res = self.client .post(path) .json(&payload) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } res.json().await.context("Sql query") } pub async fn get_users(&self) -> Result<Vec<UserSummary>> { let query: String = r#" SELECT id,nom AS first_name,last_name,email FROM users; "#.to_string(); let users_val = self.sql_query(query).await.context("Fetching users")?; Ok(serde_json::from_value(users_val.results)?) } pub async fn get_next_id(&self, table_name: &str) -> Result<u64> { let query: String = format!(r#" SELECT id FROM {} ORDER BY id DESC LIMIT 1 "#, table_name).to_string(); let data = self.sql_query(query).await.context("Fetching next id from table")?; #[derive(Deserialize)] struct Entry { id: u64 } let ids: Vec<Entry> = serde_json::from_value(data.results)?; Ok(ids.get(0).map(|x| x.id).unwrap_or(1)+1) } pub async fn get_transactions(&self, id_year: u32) -> Result<Vec<TransactionSummary>> { let query: String = format!(r#" SELECT id,reference FROM acc_transactions WHERE id_year={} AND reference LIKE 'HA/%'; "#, id_year).to_string(); let val = self.sql_query(query).await.context("Fetching transactions")?; Ok(serde_json::from_value(val.results)?) } pub async fn get_accounting_years(&self) -> Result<Vec<AccountingYear>> { let path = self.config.base_url.join("accounting/years")?; let res = self.client .get(path) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } res.json().await.context("Get accounting years") } /// get a list of membership pub async fn get_service_subscriptions(&self, service_name: &str) -> Result<Vec<Membership>> { let query: String = format!(r#" SELECT su.id,su.id_user,su.date,su.expiry_date FROM services_users AS su JOIN services AS s ON su.id_service = s.id WHERE s.label = '{}'; "#, service_name); let val = self.sql_query(query).await.context("Fetching service subscriptions")?; #[derive(Deserialize)] struct Row { id: u64, id_user: u64, #[serde(deserialize_with = "deserialize_date")] date: NaiveDate, #[serde(deserialize_with = "deserialize_date")] expiry_date: NaiveDate } let intermidiate: Vec<Row> = serde_json::from_value(val.results)?; // regroup the row with the same id Ok(intermidiate .group_by(|a,b| a.id == b.id) .map(|rows| { let base = rows.first().unwrap(); Membership { id: Id(base.id), mode_name: service_name.to_string(), service_name: "".to_string(), start_time: DateTime::<Utc>::from_naive_utc_and_offset( base.date.and_hms_opt(0, 0, 0).unwrap(), Utc ), end_time: DateTime::<Utc>::from_naive_utc_and_offset( base.expiry_date.and_hms_opt(0, 0, 0).unwrap(), Utc ), users_ids: rows.iter().map(|x| Id(x.id_user)).collect(), payed_amount: 0.0 } }).collect() ) } pub async fn create_user(&self, user: &GeneralizedAnswer, next_id: u64) -> Result<UserSummary> { // single-user import // create virtual file let u = user.clone(); let mut csv_content: String = String::new(); csv_content.push_str("numero,nom,last_name,adresse,code_postal,ville,pays,telephone,email,annee_naissance,profession,interets,lettre_infos,date_inscription\n"); csv_content.push_str( format!("{},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{:?},{},{:?},{:?},{},{}\n", next_id.to_string(), u.first_name.clone().unwrap_or("".to_string()), u.last_name.clone(), u.address, u.postal_code, u.city, u.country, u.phone.clone().unwrap_or("".to_string()), u.email.clone().unwrap_or("".to_string()), u.birth_year.map(|x| format!("{}", x)).unwrap_or("".to_string()), u.job.clone().unwrap_or("".to_string()), u.skills.clone().unwrap_or("".to_string()), 1, user.inception_time.format("%d/%m/%Y") ).as_str()); use reqwest::multipart::Form; use reqwest::multipart::Part; let part = Part::text(csv_content).file_name("file"); let form = Form::new() .part("file", part); let res = self.client .post(self.config.base_url.join("user/import/")?) .multipart(form) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } Ok( UserSummary { id: Id(next_id), first_name: u.first_name, last_name: u.last_name, email: u.email } ) } pub async fn register_user_to_service(&self, user: &UserSummary, user_membership: &Membership, next_id: u64) -> Result<UserServiceRegistration> { // single-user import // create virtual file let u = user.clone(); let mut csv_content: String = String::new(); csv_content.push_str( r#""Numéro de membre","Activité","Tarif","Date d'inscription","Date d'expiration","Montant à régler","Payé ?""#); csv_content.push('\n'); csv_content.push_str( format!("{},{:?},{:?},{:?},{:?},{:?},{:?}\n", u.id, user_membership.service_name, user_membership.mode_name, user_membership.start_time.format("%d/%m/%Y").to_string(), user_membership.end_time.format("%d/%m/%Y").to_string(), format!("{}", user_membership.payed_amount), "Oui" ).as_str()); use reqwest::multipart::Form; use reqwest::multipart::Part; let part = Part::text(csv_content).file_name("file"); let form = Form::new() .part("file", part); let res = self.client .post(self.config.base_url.join("services/subscriptions/import")?) .multipart(form) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } Ok(UserServiceRegistration { id: Id(next_id) }) } pub async fn register_transaction(&self, transaction: SimpleTransaction) -> Result<()> { use reqwest::multipart::Form; let mut form = Form::new() .text("id_year", transaction.accounting_year.to_string()) .text("label", transaction.label) .text("date", transaction.inception_time.format("%d/%m/%Y").to_string()) .text("type", Into::<String>::into(transaction.kind)) .text("amount", format!("{}", transaction.amount)) .text("debit", transaction.debit_account_code) .text("credit", transaction.credit_account_code) // "Numéro pièce comptable" enregistré au niveau de la transaction .text("reference", transaction.reference) ; for linked_id in transaction.linked_users { form = form.text("linked_users[]", format!("{}", linked_id.0)); } // only possible with paheko fork for linked_id in transaction.linked_services { form = form.text("linked_services[]", format!("{}", linked_id.0)); } let res = self.client .post(self.config.base_url.join("accounting/transaction")?) .multipart(form) .send().await?; if res.status() != 200 { return Err(APIClientError::InvalidStatusCode.into()); } Ok(()) } }