mirror of
https://github.com/danbulant/Portfolio
synced 2026-06-17 05:21:07 +00:00
feat: cli csv export
This commit is contained in:
parent
9a30f1ff8a
commit
b2cd902e98
5 changed files with 197 additions and 37 deletions
51
Cargo.lock
generated
51
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ publish = false
|
|||
|
||||
[dependencies]
|
||||
url = "^2.3"
|
||||
csv = "^1.1"
|
||||
clap = { version = "^4.0", features = ["cargo"] }
|
||||
|
||||
portfolio-entity = { path = "../entity" }
|
||||
|
|
|
|||
103
cli/src/main.rs
103
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<dyn std::error::Error>> {
|
||||
|
|
@ -15,8 +14,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.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 <PATH> "Output file path"
|
||||
)
|
||||
.required(true)
|
||||
.value_parser(value_parser!(PathBuf)),
|
||||
)
|
||||
.arg(
|
||||
arg!(
|
||||
-d --database <URL> "URL to the database or sql file with postgres:// or sqlite://"
|
||||
|
|
@ -25,6 +31,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.required(true)
|
||||
.value_parser(value_parser!(Url)),
|
||||
)
|
||||
.arg(
|
||||
arg!(
|
||||
-k --key <KEY> "AGE private key for decryption"
|
||||
)
|
||||
.required(false),
|
||||
)
|
||||
.arg(
|
||||
arg!(
|
||||
-p --password <PASSWORD> "Password for decryption"
|
||||
)
|
||||
.required(false),
|
||||
)
|
||||
.arg(
|
||||
arg!(
|
||||
-a --admin_id <ADMIN_ID> "Admin ID"
|
||||
)
|
||||
.required(false),
|
||||
)
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("portfolio")
|
||||
.about("Database & Portfolio operations")
|
||||
.arg(
|
||||
arg!(
|
||||
-p --portfolio <PATH> "Path to the portfolio root"
|
||||
|
|
@ -98,29 +126,52 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.get_matches();
|
||||
|
||||
match clap.subcommand() {
|
||||
Some(("portfolio", sub_matches)) => {
|
||||
let sqlite_url = sub_matches.get_one::<Url>("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::<Url>("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::<String>("key"), sub_matches.get_one::<String>("password")) {
|
||||
(Some(key), _) => {
|
||||
key.to_string()
|
||||
},
|
||||
(_, Some(password)) => {
|
||||
let admin_id = sub_matches.get_one::<String>("id").unwrap().parse::<i32>().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::<PathBuf>("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::<String>("input").unwrap();
|
||||
|
|
|
|||
|
|
@ -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<CandidateWithParent> for EncryptedApplicationDetails {
|
||||
type Error = ServiceError;
|
||||
|
||||
fn try_from(
|
||||
cp: CandidateWithParent,
|
||||
) -> Result<Self, Self::Error> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(FromQueryResult, Serialize)]
|
||||
pub struct CandidateWithParent { // TODO: use this instead of (Candidate, Parent)???
|
||||
pub application: i32,
|
||||
pub name: Option<String>,
|
||||
pub surname: Option<String>,
|
||||
pub birth_surname: Option<String>,
|
||||
pub birthplace: Option<String>,
|
||||
pub birthdate: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub telephone: Option<String>,
|
||||
pub citizenship: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub sex: Option<String>,
|
||||
pub study: Option<String>,
|
||||
pub personal_identification_number: Option<String>,
|
||||
|
||||
pub parent_name: Option<String>,
|
||||
pub parent_surname: Option<String>,
|
||||
pub parent_telephone: Option<String>,
|
||||
pub parent_email: Option<String>,
|
||||
}
|
||||
|
||||
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<Vec<CandidateWithParent>, 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::<CandidateWithParent>()
|
||||
.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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue