From 0f15e64ba2489928d507722bb8552103e5de4578 Mon Sep 17 00:00:00 2001 From: Matthieu Bessat Date: Thu, 28 Dec 2023 11:40:38 +0100 Subject: [PATCH] refactor: cleaning up --- src/helloasso.rs | 24 +++----- src/main.rs | 150 +++++++++++++++++++---------------------------- src/paheko.rs | 14 ++--- src/utils.rs | 6 +- 4 files changed, 76 insertions(+), 118 deletions(-) diff --git a/src/helloasso.rs b/src/helloasso.rs index 7558521..c7bf13a 100644 --- a/src/helloasso.rs +++ b/src/helloasso.rs @@ -19,10 +19,6 @@ struct WebSession { jwt: String } -pub enum LoginError { - TransportFailure(reqwest::Error) -} - #[derive(Debug)] #[fully_pub] struct Client { @@ -54,6 +50,8 @@ impl Client { fn get_base_client_builder() -> reqwest::ClientBuilder { let mut default_headers = reqwest::header::HeaderMap::new(); default_headers.insert("Accept", "application/json".parse().unwrap()); + // decoy user agent + default_headers.insert("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/112.0".parse().unwrap()); let proxy = reqwest::Proxy::https("https://localhost:8999").unwrap(); reqwest::Client::builder() @@ -92,14 +90,14 @@ impl Client { } let value = String::from(value_raw.to_str().unwrap()); if value.starts_with("tm5-HelloAsso") { - let jwt = value.split("tm5-HelloAsso=").nth(1)?.split(";").nth(0)?.trim().to_string(); + let jwt = value.split("tm5-HelloAsso=").nth(1)?.split(';').next()?.trim().to_string(); return Some(jwt); } } None } - let jwt = get_jwt_from_cookies_headers(&res.headers()) + let jwt = get_jwt_from_cookies_headers(res.headers()) .context("Failed to find or parse JWT from login response")?; let session = WebSession { jwt }; @@ -166,14 +164,6 @@ struct UserDetails { last_name: String } - -// #[derive(Debug, Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// struct OrderDetails { -// date: -// form_ -// } - impl AuthentifiedClient { /// each time we need to change the token, we will need to rebuild the client pub fn new(base_url: Url, session: WebSession) -> Self { @@ -194,7 +184,7 @@ impl AuthentifiedClient { let res = self.client .get(self.base_url.join("agg/user")?) .send().await?; - return Ok(res.status() == 200); + Ok(res.status() == 200) } pub async fn get_user_details(&self) -> Result { @@ -247,7 +237,7 @@ impl AuthentifiedClient { return Err(anyhow!("Unexpected json value in data bundle")); } }; - if page_items.len() == 0 { + if page_items.is_empty() { return Ok(data); } data.extend(page_items); @@ -319,7 +309,7 @@ impl Organization { Ok(details) } - pub async fn get_form_answers(&self, form_slug: String) -> Result> { + pub async fn get_form_answers(&self, form_slug: &str) -> Result> { let data = self.client.fetch_with_pagination( format!("organizations/{}/forms/Membership/{}/participants?withDetails=true", self.slug, form_slug) ).await?; diff --git a/src/main.rs b/src/main.rs index b5fb2bf..c1a413b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,16 +4,14 @@ mod helloasso; use thiserror::Error; use anyhow::{Context, Result}; -use chrono::prelude::{NaiveDate, NaiveDateTime, DateTime, Utc, Datelike}; +use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike}; use strum::Display; use serde::{Serialize, Deserialize}; -use std::collections::HashSet; -use phonenumber; use utils::generate_id; -use paheko::UserSummary; /// permanent config to store long-term config /// used to ingest env settings +/// config loaded from env variables #[derive(Deserialize, Serialize, Debug)] struct Config { helloasso_email: String, @@ -23,12 +21,6 @@ struct Config { paheko_client_secret: String, } -static APP_USER_AGENT: &str = concat!( - env!("CARGO_PKG_NAME"), - "/", - env!("CARGO_PKG_VERSION"), -); - // start user cache management use std::fs; @@ -51,16 +43,16 @@ enum LoadError { fn write_user_cache(cache: &UserCache) -> Result<(), LoadError> { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) - .map_err(|e| { LoadError::XDG })?; - let user_cache_path = xdg_dirs.place_cache_file("session.json").map_err(|e| { LoadError::FailedToCreate })?; - let encoded_cache = serde_json::to_string(&cache).map_err(|e| { LoadError::FailedToEncode })?; - fs::write(&user_cache_path, encoded_cache.as_str()).map_err(|e| { LoadError::FailedToWrite })?; + .map_err(|_e| { LoadError::XDG })?; + let user_cache_path = xdg_dirs.place_cache_file("session.json").map_err(|_e| { LoadError::FailedToCreate })?; + let encoded_cache = serde_json::to_string(&cache).map_err(|_e| { LoadError::FailedToEncode })?; + fs::write(user_cache_path, encoded_cache.as_str()).map_err(|_e| { LoadError::FailedToWrite })?; Ok(()) } fn load_user_cache() -> Result { let xdg_dirs = xdg::BaseDirectories::with_prefix(env!("CARGO_PKG_NAME")) - .map_err(|e| { LoadError::XDG })?; + .map_err(|_e| { LoadError::XDG })?; let user_cache_path = xdg_dirs.get_cache_file("session.json"); if !user_cache_path.exists() { @@ -70,23 +62,11 @@ fn load_user_cache() -> Result { write_user_cache(&default_cache)?; } - let session_content = fs::read_to_string(user_cache_path).map_err(|e| { LoadError::Fs })?; - let cache: UserCache = serde_json::from_str(&session_content).map_err(|e| { LoadError::FailedToParse })?; + let session_content = fs::read_to_string(user_cache_path).map_err(|_e| { LoadError::Fs })?; + let cache: UserCache = serde_json::from_str(&session_content).map_err(|_e| { LoadError::FailedToParse })?; Ok(cache) } -// todo: -// - make pagination working -// - create paheko client -// - get current paheko membership -// - function to convert participants to paheko members -// - clean up names and things -// - map custom fields with the right thing -// - handle linked users - -fn get_paheko_membership_from_ha_answers() { - -} // TODO: find a better way to have the logic implemented async fn get_auth_client_from_cache( @@ -115,16 +95,16 @@ async fn get_auth_client_from_cache( if !auth_client.verify_auth().await? { println!("Need to relog, token invalid"); - return Ok(login(user_cache, ha_client, login_payload).await?) + return login(user_cache, ha_client, login_payload).await } println!("Used anterior token"); - return Ok(auth_client); + Ok(auth_client) }, None => { println!("First time login"); - return Ok(login(user_cache, ha_client, login_payload).await?); + login(user_cache, ha_client, login_payload).await } - }; + } } @@ -165,7 +145,7 @@ fn read_custom_field(form_answer: &helloasso::FormAnswer, custom_field: Helloass // FIXME: compute the type directly at deserialization with serde form_answer.custom_fields.iter() .find(|f| HelloassoCustomFieldType::try_from(f.name.as_str()) == Ok(custom_field)) - .and_then(|cf| Some(cf.answer.clone())) + .map(|cf| cf.answer.clone()) } fn parse_normalize_phone(phone_number_opt: Option) -> Option { @@ -190,20 +170,11 @@ fn normalize_str(subject: String) -> String { /// remove year precision to comply with GDPR eu rules fn parse_and_get_birthday_year(raw_date: String) -> Option { - let d_res = NaiveDate::parse_from_str(&raw_date.trim(), "%d/%m/%Y"); + let d_res = NaiveDate::parse_from_str(raw_date.trim(), "%d/%m/%Y"); let d = d_res.ok()?; - Some(d.year().try_into().ok()?) + d.year().try_into().ok() } -// fn helloasso_to_paheko_membership(helloasso_membership: &helloasso::MembershipMode) -> paheko::MembershipMode { -// match helloasso_membership { -// helloasso::MembershipMode::Couple => paheko::MembershipMode::Couple, -// helloasso::MembershipMode::Individual => paheko::MembershipMode::Individual, -// helloasso::MembershipMode::BenefactorCouple => paheko::MembershipMode::BenefactorCouple, -// helloasso::MembershipMode::BenefactorIndividual => paheko::MembershipMode::BenefactorIndividual -// } -// } - async fn launch_adapter() -> Result<()> { dotenvy::dotenv()?; @@ -228,27 +199,23 @@ async fn launch_adapter() -> Result<()> { }; let auth_client: helloasso::AuthentifiedClient = get_auth_client_from_cache(&mut user_cache, &mut ha_client, login_payload).await?; - // dbg!(auth_client.get_user_details().await?); + // FIXME: make it configurable + let ha_org_slug = "l-etoile-de-bethleem-association-des-amis-de-la-chapelle-de-bethleem-d-aubevoye"; + // FIXME: make it configurable + let pk_target_campaign_name = "Cotisation 2023-2024"; + let ha_form_name = "2023-2024"; - let slug = "l-etoile-de-bethleem-association-des-amis-de-la-chapelle-de-bethleem-d-aubevoye"; - - let org: helloasso::Organization = auth_client.organization(slug); - // dbg!(org.get_details().await?); - let answers = org.get_form_answers("2023-2024".to_string()).await?; + let org: helloasso::Organization = auth_client.organization(ha_org_slug); + let answers = org.get_form_answers(ha_form_name).await?; // dbg!(&answers); println!("Got {} answers to the membership form. Processing...", &answers.len()); - // first, request the current list of membership in paheko that were created with helloasso - // get the list of payments associated - - // first step: output a list of PahekoUser with PahekoMembership let mut pk_memberships: Vec = vec![]; - // read_custom_field(&answer, HelloAssoCustomFieldType::Email).or(Some(answer.payer_user.email.clone())), use email_address::*; fn choose_email(answer: &helloasso::FormAnswer) -> Option { - read_custom_field(&answer, HelloassoCustomFieldType::Email) + read_custom_field(answer, HelloassoCustomFieldType::Email) .and_then(|x| { if !EmailAddress::is_valid(&x) { None @@ -259,28 +226,28 @@ async fn launch_adapter() -> Result<()> { .or(Some(answer.payer_user.email.clone())) } - // get summary of users + // 1. get summary of existing paheko users let mut existing_users = paheko_client.get_users().await.context("Get users")?; - // get summary of transactions for that year + // 2. get summary of transactions for that year let existing_transactions = paheko_client.get_transactions(1).await.context("Get transactions")?; - // TODO: before creating any users, get the current maximum id of the users table to predict - // the next auto-incrementing id. - let mut pk_next_user_id = paheko_client.get_next_id(&"users") + // query paheko to get top ids + // IMPORTANT: this mean that while the script is running, there must be NO mutations to the + // users and services_users table on the paheko side + let mut pk_next_user_id = paheko_client.get_next_id("users") .await.context("Get paheko users next id")?; - let mut pk_next_user_service_id = paheko_client.get_next_id(&"services_users") + let mut pk_next_user_service_id = paheko_client.get_next_id("services_users") .await.context("Get paheko services_users next id")?; for answer in answers { + eprintln!("Processing answer:"); + let email = choose_email(&answer); + eprintln!(" email: {:?}", email); + // list of users involved in this answer let mut pk_users_summaries: Vec = vec![]; let mut pk_user_service_registrations: Vec = vec![]; - eprintln!("Processing answer:"); - let email = choose_email(&answer); - - eprintln!(" email: {:?}", email); - let mut pk_user = paheko::User { id: utils::Id(0), first_name: Some(normalize_str(answer.user.first_name.clone())), @@ -290,12 +257,13 @@ async fn launch_adapter() -> Result<()> { skills: read_custom_field(&answer, HelloassoCustomFieldType::Skills).map(normalize_str), address: read_custom_field(&answer, HelloassoCustomFieldType::Address) .map(normalize_str) - .expect("to have address"), - postal_code: read_custom_field(&answer, HelloassoCustomFieldType::PostalCode).expect("to have postal code"), + .expect("Expected ha answer to have address"), + postal_code: read_custom_field(&answer, HelloassoCustomFieldType::PostalCode) + .expect("Expected ha answer to have postalcode"), city: read_custom_field(&answer, HelloassoCustomFieldType::City) .map(normalize_str) - .expect("to have city"), - country: answer.payer_user.country.clone().trim()[..=1].to_string(), // ISO 3166-1 alpha-2 + .expect("Expected ha answer to have city"), + country: answer.payer_user.country.clone().trim()[..=1].to_string(), // we expect country code ISO 3166-1 alpha-2 job: read_custom_field(&answer, HelloassoCustomFieldType::Job).map(normalize_str), birth_year: read_custom_field(&answer, HelloassoCustomFieldType::Birthday).and_then(parse_and_get_birthday_year), register_time: answer.order.inception_time, @@ -309,10 +277,10 @@ async fn launch_adapter() -> Result<()> { } // check for existing transactions - if let Some(_) = existing_transactions.iter().find( + if existing_transactions.iter().any( |summary| summary.reference == format!("HA/{}", answer.id) ) { - eprintln!(" Skipped: existing transaction found"); + eprintln!(" skipped: existing transaction found"); continue; } @@ -323,7 +291,7 @@ async fn launch_adapter() -> Result<()> { Some(user) => user, None => { let c = paheko_client.create_user( - &pk_user, pk_next_user_id.clone() + &pk_user, pk_next_user_id ).await.context("Expected to create paheko user")?; eprintln!(" Created paheko user"); pk_next_user_id += 1; @@ -335,7 +303,7 @@ async fn launch_adapter() -> Result<()> { let mut pk_membership = paheko::Membership { id: generate_id(), - campaign_name: "Cotisation 2023-2024".to_string(), + campaign_name: pk_target_campaign_name.to_string(), // FIXME: handle errors mode_name: serde_json::to_value(answer.mode.clone()) .unwrap().as_str().unwrap().to_string(), @@ -357,13 +325,13 @@ async fn launch_adapter() -> Result<()> { // add activity for first member let user_registration = paheko_client.register_user_to_service( - pk_users_summaries.iter().nth(0).unwrap(), + pk_users_summaries.get(0).unwrap(), &pk_membership, - pk_next_user_service_id.clone() - ).await.context("Registering user to paheko server")?; + pk_next_user_service_id + ).await.context("Expected to register user activity to paheko")?; pk_user_service_registrations.push(user_registration); pk_next_user_service_id += 1; - + eprintln!(" Created paheko activity registration"); // then create optional linked user if answer.mode == helloasso::MembershipMode::Couple { @@ -395,15 +363,13 @@ async fn launch_adapter() -> Result<()> { // create activity of second user let user_registration = paheko_client.register_user_to_service( - pk_users_summaries.iter().nth(1).unwrap(), + pk_users_summaries.get(1).unwrap(), &pk_membership, - pk_next_user_service_id.clone() + pk_next_user_service_id ).await.context("Registering service to second paheko server")?; pk_user_service_registrations.push(user_registration); pk_next_user_service_id += 1; - - // FIXME: follow the ids of the services registrations, to be able to later - // reference that user service + eprintln!(" Created paheko activity registration for conjoint user"); } // TODO: get existing linked user from previous year @@ -412,28 +378,34 @@ async fn launch_adapter() -> Result<()> { // add transaction let transaction = paheko::SimpleTransaction { - label: "Adhésion Helloasso".to_string(), + // TODO: make the label template configurable + label: format!("Adhésion {:?} via HelloAsso", pk_membership.mode_name), amount: pk_membership.payed_amount, reference: format!("HA/{}", pk_membership.external_references.helloasso_refs.answer_id), + // TODO: make these field configurable credit_account_code: "756".to_string(), // cotisations account debit_account_code: "512HA".to_string(), // helloasso account inception_time: answer.order.inception_time, kind: paheko::TransactionKind::Revenue, linked_users: pk_users_summaries.iter().map(|x| x.id.clone()).collect(), + // this depend on a patch to paheko API code to work linked_services: pk_user_service_registrations.iter().map(|x| x.id.clone()).collect() }; let _ = paheko_client.register_transaction(transaction) .await.context("Expected to create new paheko transaction"); + eprintln!(" Created paheko transaction"); pk_memberships.push(pk_membership); } - dbg!(&pk_memberships.len()); + + eprintln!(); + eprintln!("Done."); Ok(()) } #[tokio::main] async fn main() { - let res = launch_adapter().await; - dbg!(res); + // TODO: add argument parser to have handle config file + let _res = launch_adapter().await; } diff --git a/src/paheko.rs b/src/paheko.rs index 6245d9b..bea165e 100644 --- a/src/paheko.rs +++ b/src/paheko.rs @@ -3,7 +3,7 @@ use url::Url; use serde::{Serialize, Deserialize}; use fully_pub::fully_pub; use crate::utils::Id; -use chrono::prelude::{NaiveDate, DateTime, Utc, Datelike}; +use chrono::prelude::{DateTime, Utc}; use thiserror::Error; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -72,9 +72,9 @@ enum TransactionKind { Revenue } -impl Into for TransactionKind { - fn into(self) -> String { - match self { +impl From for String { + fn from(val: TransactionKind) -> Self { + match val { TransactionKind::Expense => "EXPENSE".to_string(), TransactionKind::Revenue => "REVENUE".to_string() } @@ -250,7 +250,7 @@ impl AuthentifiedClient { dbg!(res); return Err(APIClientError::InvalidStatusCode.into()); } - Ok(res.json().await.context("Sql query")?) + res.json().await.context("Sql query") } pub async fn get_users(&self) -> Result> { @@ -277,7 +277,7 @@ impl AuthentifiedClient { let ids: Vec = serde_json::from_value(data.results)?; - Ok(ids.iter().nth(0).map(|x| x.id).unwrap_or(1)+1) + Ok(ids.get(0).map(|x| x.id).unwrap_or(1)+1) } pub async fn get_transactions(&self, id_year: u32) @@ -355,7 +355,7 @@ impl AuthentifiedClient { 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_str("\n"); + csv_content.push('\n'); csv_content.push_str( format!("{},{:?},{:?},{:?},{:?},{:?},{:?}\n", u.id, diff --git a/src/utils.rs b/src/utils.rs index bc4c71f..81772e4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,11 +6,7 @@ use chrono::prelude::{DateTime, Utc}; /// ID #[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash)] pub struct Id(pub u64); -impl Id { - pub fn to_string(&self) -> String { - format!("{:x}", self.0) - } -} + impl Into for Id { fn into(self) -> String { format!("{:x}", self.0)