All checks were successful
		
		
	
	continuous-integration/drone/push Build is passing
				
			
		
			
				
	
	
		
			367 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
			
		
		
	
	
			367 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
use crate::app_config::AppConfig;
 | 
						|
use crate::constants;
 | 
						|
use crate::controllers::HttpResult;
 | 
						|
use crate::converters::finances_manager_converter::{
 | 
						|
    FinancesManagerAccount, FinancesManagerFile, FinancesManagerMovement,
 | 
						|
};
 | 
						|
use crate::extractors::auth_extractor::AuthExtractor;
 | 
						|
use crate::extractors::file_extractor::FileExtractor;
 | 
						|
use crate::models::accounts::{Account, AccountID, AccountType};
 | 
						|
use crate::models::files;
 | 
						|
use crate::models::files::FileID;
 | 
						|
use crate::models::inbox::InboxEntry;
 | 
						|
use crate::models::movements::{Movement, MovementID};
 | 
						|
use crate::services::accounts_service::UpdateAccountQuery;
 | 
						|
use crate::services::inbox_service::UpdateInboxEntryQuery;
 | 
						|
use crate::services::movements_service::UpdateMovementQuery;
 | 
						|
use crate::services::{accounts_service, files_service, inbox_service, movements_service};
 | 
						|
use crate::utils::time_utils::{format_date, time};
 | 
						|
use actix_files::NamedFile;
 | 
						|
use actix_web::{HttpRequest, HttpResponse};
 | 
						|
use rust_xlsxwriter::{Color, Format, Formula, Table, Workbook};
 | 
						|
use serde::Serialize;
 | 
						|
use serde::de::DeserializeOwned;
 | 
						|
use std::collections::HashMap;
 | 
						|
use std::fs::File;
 | 
						|
use std::io::{Cursor, Read, Seek, Write};
 | 
						|
use zip::write::SimpleFileOptions;
 | 
						|
use zip::{ZipArchive, ZipWriter};
 | 
						|
 | 
						|
#[derive(thiserror::Error, Debug)]
 | 
						|
pub enum BackupControllerError {
 | 
						|
    #[error("The account with id {0:?} does not exists!")]
 | 
						|
    NonexistentAccountId(AccountID),
 | 
						|
    #[error("The file with id {0:?} does not exists!")]
 | 
						|
    NonexistentFileId(FileID),
 | 
						|
    #[error("The movement with id {0:?} does not exists!")]
 | 
						|
    NonexistentMovementId(MovementID),
 | 
						|
}
 | 
						|
 | 
						|
/// Generate export filename
 | 
						|
fn export_filename(ext: &str) -> String {
 | 
						|
    format!(
 | 
						|
        "export_{}.{ext}",
 | 
						|
        format_date(time() as i64).unwrap().replace('/', "-")
 | 
						|
    )
 | 
						|
}
 | 
						|
 | 
						|
/// Import data from a [FinancesManager](https://gitlab.com/pierre42100/cpp-financesmanager) file
 | 
						|
pub async fn finances_manager_import(auth: AuthExtractor, file: FileExtractor) -> HttpResult {
 | 
						|
    let file = FinancesManagerFile::parse(&String::from_utf8_lossy(&file.buff))?;
 | 
						|
 | 
						|
    // Create each account & push the movements independently
 | 
						|
    for file_account in file.accounts {
 | 
						|
        let account = accounts_service::create(
 | 
						|
            auth.user_id(),
 | 
						|
            &UpdateAccountQuery {
 | 
						|
                name: file_account.name,
 | 
						|
                r#type: AccountType::Cash,
 | 
						|
            },
 | 
						|
        )
 | 
						|
        .await?;
 | 
						|
 | 
						|
        for file_movement in file_account.movements {
 | 
						|
            movements_service::create(&UpdateMovementQuery {
 | 
						|
                account_id: account.id(),
 | 
						|
                time: file_movement.time,
 | 
						|
                label: file_movement.label,
 | 
						|
                file_id: None,
 | 
						|
                amount: file_movement.amount,
 | 
						|
                checked: false,
 | 
						|
            })
 | 
						|
            .await?;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    Ok(HttpResponse::Accepted().finish())
 | 
						|
}
 | 
						|
 | 
						|
/// Export data to a [FinancesManager](https://gitlab.com/pierre42100/cpp-financesmanager) file
 | 
						|
pub async fn finances_manager_export(auth: AuthExtractor) -> HttpResult {
 | 
						|
    let mut accounts = accounts_service::get_list_user(auth.user_id()).await?;
 | 
						|
    accounts.sort_by_key(|a| a.id());
 | 
						|
 | 
						|
    let mut out = FinancesManagerFile { accounts: vec![] };
 | 
						|
 | 
						|
    for account in accounts {
 | 
						|
        let mut movements = movements_service::get_list_account(account.id()).await?;
 | 
						|
        movements.sort_by(|a, b| b.time.cmp(&a.time));
 | 
						|
 | 
						|
        let mut file_account = FinancesManagerAccount {
 | 
						|
            name: account.name,
 | 
						|
            movements: Vec::with_capacity(movements.len()),
 | 
						|
        };
 | 
						|
 | 
						|
        for movement in movements {
 | 
						|
            file_account.movements.push(FinancesManagerMovement {
 | 
						|
                label: movement.label,
 | 
						|
                time: movement.time as u64,
 | 
						|
                amount: movement.amount,
 | 
						|
            });
 | 
						|
        }
 | 
						|
 | 
						|
        out.accounts.push(file_account);
 | 
						|
    }
 | 
						|
 | 
						|
    Ok(HttpResponse::Ok()
 | 
						|
        .insert_header((
 | 
						|
            "Content-Disposition",
 | 
						|
            format!("attachment; filename={}", export_filename("finance")),
 | 
						|
        ))
 | 
						|
        .body(out.encode()))
 | 
						|
}
 | 
						|
 | 
						|
/// Add JSON file to ZIP
 | 
						|
fn zip_json<E: Serialize>(
 | 
						|
    zip: &mut ZipWriter<File>,
 | 
						|
    path: &str,
 | 
						|
    content: &E,
 | 
						|
) -> anyhow::Result<()> {
 | 
						|
    let file_encoded = serde_json::to_string(&content)?;
 | 
						|
 | 
						|
    let options = SimpleFileOptions::default()
 | 
						|
        .compression_method(zip::CompressionMethod::Deflated)
 | 
						|
        .unix_permissions(0o750);
 | 
						|
 | 
						|
    zip.start_file(path, options)?;
 | 
						|
    zip.write_all(file_encoded.as_bytes())?;
 | 
						|
    Ok(())
 | 
						|
}
 | 
						|
 | 
						|
/// Export all user data as ZIP
 | 
						|
pub async fn zip_export(req: HttpRequest, auth: AuthExtractor) -> HttpResult {
 | 
						|
    let dest_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
 | 
						|
    let zip_path = dest_dir.path().join("export.zip");
 | 
						|
 | 
						|
    let file = File::create(&zip_path)?;
 | 
						|
    let mut zip = ZipWriter::new(file);
 | 
						|
 | 
						|
    // Process all JSON documents
 | 
						|
    zip_json(
 | 
						|
        &mut zip,
 | 
						|
        constants::zip_export::ACCOUNTS_FILE,
 | 
						|
        &accounts_service::get_list_user(auth.user_id()).await?,
 | 
						|
    )?;
 | 
						|
    zip_json(
 | 
						|
        &mut zip,
 | 
						|
        constants::zip_export::MOVEMENTS_FILE,
 | 
						|
        &movements_service::get_all_movements_user(auth.user_id()).await?,
 | 
						|
    )?;
 | 
						|
    zip_json(
 | 
						|
        &mut zip,
 | 
						|
        constants::zip_export::INBOX_FILE,
 | 
						|
        &inbox_service::get_list_user(auth.user_id()).await?,
 | 
						|
    )?;
 | 
						|
    let files_list = files_service::get_all_files_user(auth.user_id()).await?;
 | 
						|
    zip_json(&mut zip, constants::zip_export::FILES_FILE, &files_list)?;
 | 
						|
 | 
						|
    // Process all files
 | 
						|
    for file in files_list {
 | 
						|
        let buff = files_service::get_file_content(&file).await?;
 | 
						|
 | 
						|
        let options = SimpleFileOptions::default()
 | 
						|
            .compression_method(zip::CompressionMethod::Deflated)
 | 
						|
            .unix_permissions(0o750);
 | 
						|
 | 
						|
        zip.start_file(
 | 
						|
            format!("{}{}", constants::zip_export::FILES_DIR, file.sha512),
 | 
						|
            options,
 | 
						|
        )?;
 | 
						|
        zip.write_all(&buff)?;
 | 
						|
    }
 | 
						|
 | 
						|
    // Finalize ZIP and return response
 | 
						|
    zip.finish()?;
 | 
						|
    let file = File::open(zip_path)?;
 | 
						|
 | 
						|
    let file = NamedFile::from_file(file, export_filename("zip"))?;
 | 
						|
 | 
						|
    Ok(file.into_response(&req))
 | 
						|
}
 | 
						|
 | 
						|
/// Read JSON from archive
 | 
						|
fn unzip_json<E: DeserializeOwned, R: Read + Seek>(
 | 
						|
    zip: &mut ZipArchive<R>,
 | 
						|
    path: &str,
 | 
						|
) -> anyhow::Result<E> {
 | 
						|
    let mut file = zip.by_name(path)?;
 | 
						|
    let mut content = String::with_capacity(file.size() as usize);
 | 
						|
    file.read_to_string(&mut content)?;
 | 
						|
 | 
						|
    Ok(serde_json::from_str(&content)?)
 | 
						|
}
 | 
						|
 | 
						|
/// Replace all data with data included in ZIP file
 | 
						|
pub async fn zip_import(auth: AuthExtractor, file: FileExtractor) -> HttpResult {
 | 
						|
    // Parse provided files
 | 
						|
    let zip_cursor = Cursor::new(file.buff);
 | 
						|
    let mut zip = ZipArchive::new(zip_cursor)?;
 | 
						|
 | 
						|
    let new_accounts: Vec<Account> = unzip_json(&mut zip, constants::zip_export::ACCOUNTS_FILE)?;
 | 
						|
    let new_movements: Vec<Movement> = unzip_json(&mut zip, constants::zip_export::MOVEMENTS_FILE)?;
 | 
						|
    let new_inbox_entries: Vec<InboxEntry> =
 | 
						|
        unzip_json(&mut zip, constants::zip_export::INBOX_FILE)?;
 | 
						|
    let new_files: Vec<files::File> = unzip_json(&mut zip, constants::zip_export::FILES_FILE)?;
 | 
						|
 | 
						|
    // Delete all data
 | 
						|
    inbox_service::delete_all_user(auth.user_id()).await?;
 | 
						|
    accounts_service::delete_all_user(auth.user_id()).await?;
 | 
						|
    files_service::delete_all_user(auth.user_id()).await?;
 | 
						|
 | 
						|
    // Create the files
 | 
						|
    let mut files_mapping = HashMap::new();
 | 
						|
    for file in new_files {
 | 
						|
        let mut zip_file = zip
 | 
						|
            .by_name(&format!(
 | 
						|
                "{}{}",
 | 
						|
                constants::zip_export::FILES_DIR,
 | 
						|
                file.sha512
 | 
						|
            ))
 | 
						|
            .map_err(|e| anyhow::anyhow!("Could not find file with hash {}: {}", file.sha512, e))?;
 | 
						|
        let mut file_buff = Vec::with_capacity(zip_file.size() as usize);
 | 
						|
        zip_file.read_to_end(&mut file_buff)?;
 | 
						|
        let created_file =
 | 
						|
            files_service::create_file_with_file_name(auth.user_id(), &file.file_name, &file_buff)
 | 
						|
                .await?;
 | 
						|
        files_mapping.insert(file.id(), created_file);
 | 
						|
    }
 | 
						|
 | 
						|
    // Create the accounts
 | 
						|
    let mut accounts_mapping = HashMap::new();
 | 
						|
    for account in new_accounts {
 | 
						|
        let created_account = accounts_service::create(
 | 
						|
            auth.user_id(),
 | 
						|
            &UpdateAccountQuery {
 | 
						|
                name: account.name.to_string(),
 | 
						|
                r#type: account.account_type(),
 | 
						|
            },
 | 
						|
        )
 | 
						|
        .await?;
 | 
						|
 | 
						|
        if account.default_account {
 | 
						|
            accounts_service::set_default(auth.user_id(), created_account.id()).await?;
 | 
						|
        }
 | 
						|
 | 
						|
        accounts_mapping.insert(account.id(), created_account);
 | 
						|
    }
 | 
						|
 | 
						|
    // Create the movements
 | 
						|
    let mut movements_mapping = HashMap::new();
 | 
						|
    for movement in new_movements {
 | 
						|
        let created_movement = movements_service::create(&UpdateMovementQuery {
 | 
						|
            account_id: accounts_mapping
 | 
						|
                .get(&movement.account_id())
 | 
						|
                .ok_or_else(|| BackupControllerError::NonexistentAccountId(movement.account_id()))?
 | 
						|
                .id(),
 | 
						|
            time: movement.time as u64,
 | 
						|
            label: movement.label.to_string(),
 | 
						|
            file_id: movement
 | 
						|
                .file_id()
 | 
						|
                .map(|file_id| {
 | 
						|
                    files_mapping
 | 
						|
                        .get(&file_id)
 | 
						|
                        .ok_or(BackupControllerError::NonexistentFileId(file_id))
 | 
						|
                })
 | 
						|
                .transpose()?
 | 
						|
                .map(|f| f.id()),
 | 
						|
            amount: movement.amount,
 | 
						|
            checked: movement.checked,
 | 
						|
        })
 | 
						|
        .await?;
 | 
						|
 | 
						|
        movements_mapping.insert(movement.id(), created_movement);
 | 
						|
    }
 | 
						|
 | 
						|
    // Create the inbox entries
 | 
						|
    for inbox_entry in new_inbox_entries {
 | 
						|
        inbox_service::create(
 | 
						|
            auth.user_id(),
 | 
						|
            &UpdateInboxEntryQuery {
 | 
						|
                file_id: files_mapping
 | 
						|
                    .get(&inbox_entry.file_id())
 | 
						|
                    .ok_or(BackupControllerError::NonexistentFileId(
 | 
						|
                        inbox_entry.file_id(),
 | 
						|
                    ))?
 | 
						|
                    .id(),
 | 
						|
                movement_id: inbox_entry
 | 
						|
                    .movement_id()
 | 
						|
                    .map(|mov_id| {
 | 
						|
                        movements_mapping
 | 
						|
                            .get(&mov_id)
 | 
						|
                            .ok_or(BackupControllerError::NonexistentMovementId(mov_id))
 | 
						|
                    })
 | 
						|
                    .transpose()?
 | 
						|
                    .map(|f| f.id()),
 | 
						|
                time: inbox_entry.time as u64,
 | 
						|
                label: inbox_entry.label,
 | 
						|
                amount: inbox_entry.amount,
 | 
						|
            },
 | 
						|
        )
 | 
						|
        .await?;
 | 
						|
    }
 | 
						|
 | 
						|
    Ok(HttpResponse::Accepted().finish())
 | 
						|
}
 | 
						|
 | 
						|
/// Export all movement to XSLX
 | 
						|
pub async fn xslx_export(auth: AuthExtractor) -> HttpResult {
 | 
						|
    let mut workbook = Workbook::new();
 | 
						|
 | 
						|
    for account in accounts_service::get_list_user(auth.user_id()).await? {
 | 
						|
        let worksheet = workbook.add_worksheet();
 | 
						|
        worksheet.set_name(account.name.to_string())?;
 | 
						|
 | 
						|
        // Configure columns
 | 
						|
        let header_format = Format::new()
 | 
						|
            .set_bold()
 | 
						|
            .set_background_color(Color::Black)
 | 
						|
            .set_foreground_color(Color::White);
 | 
						|
 | 
						|
        worksheet.set_column_width(0, 15)?;
 | 
						|
        worksheet.set_column_width(1, 70)?;
 | 
						|
        worksheet.set_column_width(2, 15)?;
 | 
						|
 | 
						|
        // Write headers
 | 
						|
        worksheet.write_with_format(0, 0, "Date", &header_format)?;
 | 
						|
        worksheet.write_with_format(0, 1, "Label", &header_format)?;
 | 
						|
        worksheet.write_with_format(0, 2, "Amount", &header_format)?;
 | 
						|
 | 
						|
        // Write movements
 | 
						|
        let mut movements = movements_service::get_list_account(account.id()).await?;
 | 
						|
        movements.sort_by(|a, b| a.time.to_string().cmp(&b.time.to_string()));
 | 
						|
        for (idx, movement) in movements.iter().enumerate() {
 | 
						|
            worksheet.write(idx as u32 + 1, 0, format_date(movement.time)?)?;
 | 
						|
            worksheet.write(idx as u32 + 1, 1, &movement.label)?;
 | 
						|
            worksheet.write(idx as u32 + 1, 2, movement.amount)?;
 | 
						|
        }
 | 
						|
 | 
						|
        // Create table
 | 
						|
        let table = Table::new();
 | 
						|
        worksheet.add_table(0, 0, movements.len() as u32, 2, &table)?;
 | 
						|
 | 
						|
        // Add total
 | 
						|
        worksheet.write_with_format(5, 5, "Total", &header_format)?;
 | 
						|
        worksheet.write_formula(
 | 
						|
            6,
 | 
						|
            5,
 | 
						|
            Formula::new(format!("SUM(C1,C{})", movements.len())).set_result(
 | 
						|
                movements
 | 
						|
                    .iter()
 | 
						|
                    .fold(0f32, |acc, m| acc + m.amount)
 | 
						|
                    .to_string(),
 | 
						|
            ),
 | 
						|
        )?;
 | 
						|
    }
 | 
						|
 | 
						|
    // Save final Excel Document
 | 
						|
    let raw_excel = workbook.save_to_buffer()?;
 | 
						|
 | 
						|
    Ok(HttpResponse::Ok()
 | 
						|
        .content_type("application/vnd.ms-excel")
 | 
						|
        .insert_header((
 | 
						|
            "Content-Disposition",
 | 
						|
            format!("attachment; filename={}", export_filename("xslx")),
 | 
						|
        ))
 | 
						|
        .body(raw_excel))
 | 
						|
}
 |