From b2cd902e987436b81318210fcb725f5864dc762a Mon Sep 17 00:00:00 2001 From: Sebastian Pravda Date: Wed, 23 Nov 2022 11:46:28 +0100 Subject: [PATCH] feat: cli csv export --- Cargo.lock | 51 +++++++++++-- cli/Cargo.toml | 1 + cli/src/main.rs | 103 ++++++++++++++++++++------- core/src/candidate_details.rs | 32 ++++++++- core/src/database/query/candidate.rs | 47 ++++++++++-- 5 files changed, 197 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index baa4654..6397fad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,6 +354,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.11.1" @@ -645,6 +657,28 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa 0.4.8", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1226,7 +1260,7 @@ checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" dependencies = [ "bytes", "fnv", - "itoa", + "itoa 1.0.4", ] [[package]] @@ -1267,7 +1301,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa", + "itoa 1.0.4", "pin-project-lite", "socket2", "tokio", @@ -1456,6 +1490,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + [[package]] name = "itoa" version = "1.0.4" @@ -2000,6 +2040,7 @@ name = "portfolio-cli" version = "0.1.0" dependencies = [ "clap 4.0.23", + "csv", "portfolio-core", "portfolio-entity", "sea-orm", @@ -2706,7 +2747,7 @@ version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" dependencies = [ - "itoa", + "itoa 1.0.4", "ryu", "serde", ] @@ -2862,7 +2903,7 @@ dependencies = [ "hkdf", "hmac", "indexmap", - "itoa", + "itoa 1.0.4", "libc", "libsqlite3-sys", "log", @@ -3060,7 +3101,7 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" dependencies = [ - "itoa", + "itoa 1.0.4", "serde", "time-core", "time-macros", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2a327db..7094fb6 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] url = "^2.3" +csv = "^1.1" clap = { version = "^4.0", features = ["cargo"] } portfolio-entity = { path = "../entity" } diff --git a/cli/src/main.rs b/cli/src/main.rs index 9c69d96..8255e91 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,12 +1,11 @@ use std::path::PathBuf; + +use clap::{arg, ArgAction, command, Command, value_parser}; +use sea_orm::{Database, DatabaseConnection}; use url::Url; -use clap::{arg, command, value_parser, ArgAction, Command}; -use sea_orm::{Database, DatabaseConnection}; - -use ::entity::candidate::Entity as Candidate; -use ::entity::parent::Entity as Parent; -use sea_orm::*; +use portfolio_core::{crypto, Query}; +use portfolio_core::candidate_details::{ApplicationDetails, EncryptedApplicationDetails}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -15,8 +14,15 @@ async fn main() -> Result<(), Box> { .subcommand_required(true) .arg_required_else_help(true) .subcommand( - Command::new("portfolio") - .about("Database & Portfolio operations") + Command::new("export") + .about("Export all candidate data to a CSV file") + .arg( + arg!( + -o --output "Output file path" + ) + .required(true) + .value_parser(value_parser!(PathBuf)), + ) .arg( arg!( -d --database "URL to the database or sql file with postgres:// or sqlite://" @@ -25,6 +31,28 @@ async fn main() -> Result<(), Box> { .required(true) .value_parser(value_parser!(Url)), ) + .arg( + arg!( + -k --key "AGE private key for decryption" + ) + .required(false), + ) + .arg( + arg!( + -p --password "Password for decryption" + ) + .required(false), + ) + .arg( + arg!( + -a --admin_id "Admin ID" + ) + .required(false), + ) + ) + .subcommand( + Command::new("portfolio") + .about("Database & Portfolio operations") .arg( arg!( -p --portfolio "Path to the portfolio root" @@ -98,29 +126,52 @@ async fn main() -> Result<(), Box> { .get_matches(); match clap.subcommand() { - Some(("portfolio", sub_matches)) => { - let sqlite_url = sub_matches.get_one::("database").unwrap(); - - println!("Connecting to {:?}", sqlite_url); - - if sqlite_url.scheme() != "sqlite" && sqlite_url.scheme() != "postgres" { + Some(("export", sub_matches)) => { + let db_url = sub_matches.get_one::("database").unwrap(); + if db_url.scheme() != "sqlite" && db_url.scheme() != "postgres" { return Err("URL scheme postgres:// or sqlite:// required")?; } + let db: DatabaseConnection = Database::connect(db_url.as_str()).await?; - let db: DatabaseConnection = Database::connect(sqlite_url.as_str()).await?; + let key = match (sub_matches.get_one::("key"), sub_matches.get_one::("password")) { + (Some(key), _) => { + key.to_string() + }, + (_, Some(password)) => { + let admin_id = sub_matches.get_one::("id").unwrap().parse::().unwrap(); + let admin = Query::find_admin_by_id(&db, admin_id) + .await + .map_err(|e| format!("Admin {} not found", admin_id))? + .ok_or("Admin not found")?; + crypto::decrypt_password( + admin.private_key, + password.to_string() + ).await? - let entries = Candidate::find() - .join_rev( - JoinType::InnerJoin, - Parent::belongs_to(Candidate) - .from(::entity::parent::Column::Application) - .to(::entity::candidate::Column::Application) - .into(), - ) - .all(&db) - .await?; + }, + _ => { + unreachable!("Either key or password must be provided"); + } + }; + + let output = sub_matches.get_one::("output").unwrap(); + let mut csv = csv::Writer::from_path(output)?; - println!("Found {} entries", entries.len()); + let candidates_with_parents = Query::list_all_candidates_with_parents(&db).await?; + for candidate in candidates_with_parents { + let application = candidate.application; + + if let Ok(enc_details) = EncryptedApplicationDetails::try_from(candidate) { + let details = enc_details.decrypt(key.to_string()).await?; + csv.serialize(details)?; + } else { + println!("Failed to decrypt candidate {} (Candidate data not set)", application); + } + } + csv.flush()?; + }, + Some(("portfolio", sub_matches)) => { + todo!() } Some(("hash", sub_matches)) => { let input = sub_matches.get_one::("input").unwrap(); diff --git a/core/src/candidate_details.rs b/core/src/candidate_details.rs index ea4616a..29c40e6 100644 --- a/core/src/candidate_details.rs +++ b/core/src/candidate_details.rs @@ -1,8 +1,9 @@ use chrono::NaiveDate; -use entity::{candidate, parent}; use serde::{Deserialize, Serialize}; -use crate::{crypto, error::ServiceError}; +use entity::{candidate, parent}; + +use crate::{crypto, database::query::candidate::CandidateWithParent, error::ServiceError}; pub const NAIVE_DATE_FMT: &str = "%Y-%m-%d"; @@ -184,6 +185,33 @@ impl TryFrom<(candidate::Model, parent::Model)> for EncryptedApplicationDetails } } +impl TryFrom for EncryptedApplicationDetails { + type Error = ServiceError; + + fn try_from( + cp: CandidateWithParent, + ) -> Result { + Ok(EncryptedApplicationDetails { + name: EncryptedString::try_from(cp.name)?, + surname: EncryptedString::try_from(cp.surname)?, + birthplace: EncryptedString::try_from(cp.birthplace)?, + birthdate: EncryptedString::try_from(cp.birthdate)?, + address: EncryptedString::try_from(cp.address)?, + telephone: EncryptedString::try_from(cp.telephone)?, + citizenship: EncryptedString::try_from(cp.citizenship)?, + email: EncryptedString::try_from(cp.email)?, + sex: EncryptedString::try_from(cp.sex)?, + study: cp.study.ok_or(ServiceError::CandidateDetailsNotSet)?, + + parent_name: EncryptedString::try_from(cp.parent_name)?, + parent_surname: EncryptedString::try_from(cp.parent_surname)?, + parent_telephone: EncryptedString::try_from(cp.parent_telephone)?, + parent_email: EncryptedString::try_from(cp.parent_email)?, + }) + } +} + + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct ApplicationDetails { diff --git a/core/src/database/query/candidate.rs b/core/src/database/query/candidate.rs index 64b8559..fd60071 100644 --- a/core/src/database/query/candidate.rs +++ b/core/src/database/query/candidate.rs @@ -1,9 +1,9 @@ -use crate::{Query}; - -use ::entity::{candidate, candidate::Entity as Candidate, parent}; use sea_orm::*; use serde::Serialize; +use ::entity::{candidate, candidate::Entity as Candidate, parent}; + +use crate::Query; pub const PAGE_SIZE: u64 = 20; @@ -19,6 +19,28 @@ pub struct CandidateParentResult { pub parent_surname: Option, } +#[derive(FromQueryResult, Serialize)] +pub struct CandidateWithParent { // TODO: use this instead of (Candidate, Parent)??? + pub application: i32, + pub name: Option, + pub surname: Option, + pub birth_surname: Option, + pub birthplace: Option, + pub birthdate: Option, + pub address: Option, + pub telephone: Option, + pub citizenship: Option, + pub email: Option, + pub sex: Option, + pub study: Option, + pub personal_identification_number: Option, + + pub parent_name: Option, + pub parent_surname: Option, + pub parent_telephone: Option, + pub parent_email: Option, +} + impl Query { pub async fn find_candidate_by_id( db: &DbConn, @@ -53,14 +75,31 @@ impl Query { query.fetch().await } } + + + pub async fn list_all_candidates_with_parents( + db: &DbConn, + ) -> Result, DbErr> { + Candidate::find() + .order_by(candidate::Column::Application, Order::Asc) + .join(JoinType::InnerJoin, candidate::Relation::Parent.def()) + .column_as(parent::Column::Name, "parent_name") + .column_as(parent::Column::Surname, "parent_surname") + .column_as(parent::Column::Telephone, "parent_telephone") + .column_as(parent::Column::Email, "parent_email") + .into_model::() + .all(db) + .await + } } #[cfg(test)] mod tests { - use entity::candidate; use sea_orm::{ActiveModelTrait, Set}; + use entity::candidate; + use crate::Query; use crate::util::get_memory_sqlite_connection;