helloasso-paheko-adapter/src/paheko.rs

544 lines
16 KiB
Rust

use async_recursion::async_recursion;
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>,
phone: 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 {
self.show_paheko_err(res).await;
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,telephone AS phone 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 {
self.show_paheko_err(res).await;
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()
)
}
#[async_recursion]
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 {
let res_text = res.text().await.unwrap();
if res_text.contains("E-Mail") && res_text.contains("unique") {
eprintln!("WARN: Detected duplicated email, will retry without email");
// email detected as duplicated by paheko server
let mut new_data = user.clone();
new_data.email = None;
return self.create_user(&new_data, next_id).await;
}
// self.show_paheko_err(res).await;
return Err(APIClientError::InvalidStatusCode.into());
}
Ok(
UserSummary {
id: Id(next_id),
first_name: u.first_name,
last_name: u.last_name,
email: u.email,
phone: u.phone
}
)
}
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 {
self.show_paheko_err(res).await;
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 {
self.show_paheko_err(res).await;
return Err(APIClientError::InvalidStatusCode.into());
}
Ok(())
}
async fn show_paheko_err(&self, err_response: reqwest::Response) -> () {
eprintln!("Paheko error details: {:?} {:?}", err_response.status(), err_response.text().await.unwrap())
}
}