diff --git a/src-tauri/src/apps/contractapp/filters.rs b/src-tauri/src/apps/contractapp/filters.rs index e7ecd66..acf636f 100644 --- a/src-tauri/src/apps/contractapp/filters.rs +++ b/src-tauri/src/apps/contractapp/filters.rs @@ -1,6 +1,6 @@ use che_tauri::{Filter, FilterSet}; -use super::models::{Contract, Counterparty}; +use super::models::{Contract, ContractApplicationFile, Counterparty}; static CONTRACTAPP_FILTERS: &[Filter] = &[ Filter::exact("id"), @@ -30,6 +30,15 @@ static COUNTERPARTY_FILTERS: &[Filter] = &[ Filter::contains("name"), ]; +static CONTRACT_APPLICATION_FILE_FILTERS: &[Filter] = &[ + Filter::exact("id"), + Filter::exact("contract_id"), + Filter::exact("name"), + Filter::contains("name"), + Filter::exact("file_type"), + Filter::exact("status"), +]; + pub fn contractapp_filterset() -> FilterSet { FilterSet::new(CONTRACTAPP_FILTERS) } @@ -37,3 +46,7 @@ pub fn contractapp_filterset() -> FilterSet { pub fn counterparty_filterset() -> FilterSet { FilterSet::new(COUNTERPARTY_FILTERS) } + +pub fn contract_application_file_filterset() -> FilterSet { + FilterSet::new(CONTRACT_APPLICATION_FILE_FILTERS) +} diff --git a/src-tauri/src/apps/contractapp/migrations/0005_auto.sql b/src-tauri/src/apps/contractapp/migrations/0005_auto.sql new file mode 100644 index 0000000..4ad2815 --- /dev/null +++ b/src-tauri/src/apps/contractapp/migrations/0005_auto.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS contract_application_file ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contract_id INTEGER NOT NULL REFERENCES contractapp(id), + name TEXT NOT NULL, + file_type TEXT NOT NULL, + file_type_display TEXT NOT NULL, + status TEXT NOT NULL, + status_display TEXT NOT NULL, + absolute_url TEXT NOT NULL, + scan_name TEXT NOT NULL, + scan_url TEXT NOT NULL, + local_path TEXT NOT NULL, + comment TEXT NOT NULL, + bill_total REAL NOT NULL, + bill_cost_total REAL NOT NULL, + questionnair INTEGER +); diff --git a/src-tauri/src/apps/contractapp/migrations/schema.json b/src-tauri/src/apps/contractapp/migrations/schema.json index 5af1edb..c4455af 100644 --- a/src-tauri/src/apps/contractapp/migrations/schema.json +++ b/src-tauri/src/apps/contractapp/migrations/schema.json @@ -1,5 +1,178 @@ { "models": [ + { + "table": "contract_application_file", + "fields": [ + { + "name": "id", + "ty": "integer", + "primary_key": true, + "nullable": false, + "auto": true, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "contract_id", + "ty": "integer", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": { + "table": "contractapp", + "column": "id" + } + }, + { + "name": "name", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "file_type", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "file_type_display", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "status", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "status_display", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "absolute_url", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "scan_name", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "scan_url", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "local_path", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "comment", + "ty": "text", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "bill_total", + "ty": "real", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "bill_cost_total", + "ty": "real", + "primary_key": false, + "nullable": false, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + }, + { + "name": "questionnair", + "ty": "integer", + "primary_key": false, + "nullable": true, + "auto": false, + "unique": false, + "max_length": null, + "default": null, + "foreign_key": null + } + ] + }, { "table": "contractapp", "fields": [ diff --git a/src-tauri/src/apps/contractapp/mod.rs b/src-tauri/src/apps/contractapp/mod.rs index 892a94f..a26f4da 100644 --- a/src-tauri/src/apps/contractapp/mod.rs +++ b/src-tauri/src/apps/contractapp/mod.rs @@ -26,5 +26,10 @@ impl AppModule for ContractappModule { serializers::counterparty_serializer(), filters::counterparty_filterset(), ); + ctx.resource::( + "contract_application_file", + serializers::contract_application_file_serializer(), + filters::contract_application_file_filterset(), + ); } } diff --git a/src-tauri/src/apps/contractapp/models.rs b/src-tauri/src/apps/contractapp/models.rs index f042738..d8303ab 100644 --- a/src-tauri/src/apps/contractapp/models.rs +++ b/src-tauri/src/apps/contractapp/models.rs @@ -56,3 +56,27 @@ pub struct Counterparty { pub name: String, } + +#[derive(Debug, Clone, Model)] +#[model(table = "contract_application_file")] +pub struct ContractApplicationFile { + #[field(primary_key)] + pub id: i64, + + #[field(foreign_key = Contract)] + pub contract_id: i64, + + pub name: String, + pub file_type: String, + pub file_type_display: String, + pub status: String, + pub status_display: String, + pub absolute_url: String, + pub scan_name: String, + pub scan_url: String, + pub local_path: String, + pub comment: String, + pub bill_total: f64, + pub bill_cost_total: f64, + pub questionnair: Option, +} diff --git a/src-tauri/src/apps/contractapp/serializers.rs b/src-tauri/src/apps/contractapp/serializers.rs index baf94d0..a375405 100644 --- a/src-tauri/src/apps/contractapp/serializers.rs +++ b/src-tauri/src/apps/contractapp/serializers.rs @@ -6,7 +6,7 @@ use crate::apps::{ projectapp::{models::Project, serializers::project_serializer}, }; -use super::models::{Contract, Counterparty}; +use super::models::{Contract, ContractApplicationFile, Counterparty}; static CONTRACT_FIELDS: &[Field] = &[ Field::new("id").read_only(), @@ -45,8 +45,27 @@ static CONTRACT_FIELDS: &[Field] = &[ ]; static COUNTERPARTY_FIELDS: &[Field] = &[Field::new("id").read_only(), Field::new("name")]; +static CONTRACT_APPLICATION_FILE_FIELDS: &[Field] = &[ + Field::new("id").read_only(), + Field::new("contract_id"), + Field::new("name"), + Field::new("file_type"), + Field::new("file_type_display"), + Field::new("status"), + Field::new("status_display"), + Field::new("absolute_url"), + Field::new("scan_name"), + Field::new("scan_url"), + Field::new("local_path"), + Field::new("comment"), + Field::new("bill_total"), + Field::new("bill_cost_total"), + Field::new("questionnair").required(false).nullable(), + Field::related("contract", "contract_id", &CONTRACT_RELATION), +]; static COUNTERPARTY_RELATION: RelatedModel = RelatedModel::new(counterparty_serializer); +static CONTRACT_RELATION: RelatedModel = RelatedModel::new(contractapp_serializer); static PROJECT_RELATION: RelatedModel = RelatedModel::new(project_serializer); static FRC_RELATION: RelatedModel = RelatedModel::new(frc_serializer); static EMPLOYEE_RELATION: RelatedModel = RelatedModel::new(employee_serializer); @@ -58,3 +77,7 @@ pub fn contractapp_serializer() -> ModelSerializer { pub fn counterparty_serializer() -> ModelSerializer { ModelSerializer::new(COUNTERPARTY_FIELDS) } + +pub fn contract_application_file_serializer() -> ModelSerializer { + ModelSerializer::new(CONTRACT_APPLICATION_FILE_FIELDS) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7859e1e..8afb72c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod sync; use che_tauri::{ApiError, ApiRequest, AppState, AuthTokenResponse, TauriApi}; +use tauri::Manager; use crate::sync::SyncContractsResult; @@ -43,8 +44,15 @@ fn auth_status(api: tauri::State<'_, TauriApi>) -> bool { } #[tauri::command] -async fn sync_contracts(api: tauri::State<'_, TauriApi>) -> Result { - sync::sync_contracts(&api).await +async fn sync_contracts( + app: tauri::AppHandle, + api: tauri::State<'_, TauriApi>, +) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|error| ApiError::new("file_error", error.to_string()))?; + sync::sync_contracts(&api, app_data_dir).await } #[cfg_attr(mobile, tauri::mobile_entry_point)] diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index d65194f..72ff5ee 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use che_orm::__private::sqlx; use che_tauri::{ApiError, TauriApi}; use serde::Deserialize; @@ -6,6 +8,8 @@ use serde::Deserialize; pub struct SyncContractsResult { pub synced: usize, pub pages: usize, + pub applications: usize, + pub files_downloaded: usize, } #[derive(Debug, Deserialize)] @@ -66,6 +70,33 @@ struct RemoteBillCost { paid_sum: Option, } +#[derive(Debug, Deserialize)] +struct RemoteContractDetail { + #[serde(default)] + contractapplicationfile_set: Vec, +} + +#[derive(Debug, Deserialize)] +struct RemoteContractApplicationFile { + id: i64, + name: String, + file_type: String, + #[serde(rename = "get_file_type_display")] + file_type_display: String, + status: String, + #[serde(rename = "get_status_display")] + status_display: String, + #[serde(rename = "get_absolute_url")] + absolute_url: String, + #[serde(rename = "get_scan")] + scan_name: String, + scan: String, + comment: String, + bill_total: f64, + bill_cost_total: f64, + questionnair: Option, +} + #[derive(Debug, Deserialize)] struct RemoteCompany { id: i64, @@ -106,7 +137,10 @@ struct RemoteEmployee { avatar_small: Option, } -pub async fn sync_contracts(api: &TauriApi) -> Result { +pub async fn sync_contracts( + api: &TauriApi, + app_data_dir: PathBuf, +) -> Result { let state = api.state(); let token = state .auth_token() @@ -123,6 +157,8 @@ pub async fn sync_contracts(api: &TauriApi) -> Result Result Result { + let response = client + .get(format!( + "{}/api/contract/{contract_id}/", + base_url.trim_end_matches('/') + )) + .header(reqwest::header::AUTHORIZATION, format!("Token {token}")) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(ApiError::new( + "remote_error", + format!("remote detail request failed with {status}: {detail}"), + )); + } + + Ok(response.json::().await?) +} + +async fn download_application_file( + client: &reqwest::Client, + token: &str, + app_data_dir: &PathBuf, + application: &RemoteContractApplicationFile, +) -> Result { + if application.scan.is_empty() { + return Ok(String::new()); + } + + let directory = application_download_dir(app_data_dir); + tokio::fs::create_dir_all(&directory) + .await + .map_err(file_error)?; + + let file_name = format!("{}_{}", application.id, sanitize_file_name(&application.scan_name)); + let path = directory.join(file_name); + + let response = client + .get(&application.scan) + .header(reqwest::header::AUTHORIZATION, format!("Token {token}")) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + return Err(ApiError::new( + "remote_error", + format!("file download failed with {status}: {detail}"), + )); + } + + let bytes = response.bytes().await?; + tokio::fs::write(&path, bytes).await.map_err(file_error)?; + + Ok(path.to_string_lossy().into_owned()) +} + +fn application_download_dir(app_data_dir: &PathBuf) -> PathBuf { + app_data_dir.join("contract_applications") +} + +fn sanitize_file_name(file_name: &str) -> String { + let sanitized = file_name + .chars() + .map(|character| match character { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => character, + }) + .collect::(); + + if sanitized.is_empty() { + "application_file".to_string() + } else { + sanitized + } } async fn upsert_contract_dependencies( @@ -316,6 +455,61 @@ async fn upsert_contract(api: &TauriApi, contract: &RemoteContract) -> Result<() Ok(()) } +async fn upsert_contract_application_file( + api: &TauriApi, + contract_id: i64, + application: &RemoteContractApplicationFile, + local_path: &str, +) -> Result<(), ApiError> { + sqlx::query( + "INSERT INTO contract_application_file ( + id, contract_id, name, file_type, file_type_display, status, status_display, + absolute_url, scan_name, scan_url, local_path, comment, bill_total, + bill_cost_total, questionnair + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) + ON CONFLICT(id) DO UPDATE SET + contract_id = excluded.contract_id, + name = excluded.name, + file_type = excluded.file_type, + file_type_display = excluded.file_type_display, + status = excluded.status, + status_display = excluded.status_display, + absolute_url = excluded.absolute_url, + scan_name = excluded.scan_name, + scan_url = excluded.scan_url, + local_path = excluded.local_path, + comment = excluded.comment, + bill_total = excluded.bill_total, + bill_cost_total = excluded.bill_cost_total, + questionnair = excluded.questionnair", + ) + .bind(application.id) + .bind(contract_id) + .bind(&application.name) + .bind(&application.file_type) + .bind(&application.file_type_display) + .bind(&application.status) + .bind(&application.status_display) + .bind(&application.absolute_url) + .bind(&application.scan_name) + .bind(&application.scan) + .bind(local_path) + .bind(&application.comment) + .bind(application.bill_total) + .bind(application.bill_cost_total) + .bind(application.questionnair) + .execute(api.state().db().pool()) + .await + .map_err(database_error)?; + + Ok(()) +} + fn database_error(error: sqlx::Error) -> ApiError { ApiError::new("database_error", error.to_string()) } + +fn file_error(error: impl std::fmt::Display) -> ApiError { + ApiError::new("file_error", error.to_string()) +} diff --git a/src/generated/api.ts b/src/generated/api.ts index 301db16..5ecc261 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -4,6 +4,10 @@ import type { ContractCreate, ContractUpdate, ContractListParams, + ContractApplicationFile, + ContractApplicationFileCreate, + ContractApplicationFileUpdate, + ContractApplicationFileListParams, Counterparty, CounterpartyCreate, CounterpartyUpdate, @@ -35,6 +39,7 @@ import type { } from "./models"; export const contractApi = createModelApi("contract"); +export const contractApplicationFileApi = createModelApi("contract_application_file"); export const counterpartyApi = createModelApi("counterparty"); export const employeeApi = createModelApi("employee"); export const frcApi = createModelApi("frc"); diff --git a/src/generated/models.ts b/src/generated/models.ts index bab6d9f..73aa1ad 100644 --- a/src/generated/models.ts +++ b/src/generated/models.ts @@ -120,6 +120,68 @@ export interface ContractListParams extends ListParams { employee_id?: number | null; } +export interface ContractApplicationFile { + id: number; + contract_id: number; + name: string; + file_type: string; + file_type_display: string; + status: string; + status_display: string; + absolute_url: string; + scan_name: string; + scan_url: string; + local_path: string; + comment: string; + bill_total: number; + bill_cost_total: number; + questionnair: number | null; + contract: Contract; +} + +export interface ContractApplicationFileCreate { + contract_id: number; + name: string; + file_type: string; + file_type_display: string; + status: string; + status_display: string; + absolute_url: string; + scan_name: string; + scan_url: string; + local_path: string; + comment: string; + bill_total: number; + bill_cost_total: number; + questionnair?: number | null; +} + +export interface ContractApplicationFileUpdate { + contract_id?: number; + name?: string; + file_type?: string; + file_type_display?: string; + status?: string; + status_display?: string; + absolute_url?: string; + scan_name?: string; + scan_url?: string; + local_path?: string; + comment?: string; + bill_total?: number; + bill_cost_total?: number; + questionnair?: number | null; +} + +export interface ContractApplicationFileListParams extends ListParams { + id?: number; + contract_id?: number; + name?: string; + name__contains?: string; + file_type?: string; + status?: string; +} + export interface Counterparty { id: number; name: string; diff --git a/src/views/ContractDetailView.vue b/src/views/ContractDetailView.vue index a5d305e..12c0edf 100644 --- a/src/views/ContractDetailView.vue +++ b/src/views/ContractDetailView.vue @@ -1,8 +1,10 @@