diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..da206c6 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/.idea/inventory-app.iml b/.idea/inventory-app.iml index bbe0a70..8d34de7 100644 --- a/.idea/inventory-app.iml +++ b/.idea/inventory-app.iml @@ -8,5 +8,6 @@ + \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000..2b01732 --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 93e4e24..3600dd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1154,6 +1154,7 @@ dependencies = [ "axum", "axum-extra", "axum-htmx", + "chrono", "csv", "dotenvy", "httpc-test", @@ -1162,7 +1163,6 @@ dependencies = [ "ron", "serde", "sqlx", - "time", "tokio", "tower 0.5.1", "tower-http", @@ -2240,6 +2240,7 @@ dependencies = [ "atoi", "byteorder", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -2321,6 +2322,7 @@ dependencies = [ "bitflags 2.6.0", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -2363,6 +2365,7 @@ dependencies = [ "base64 0.22.1", "bitflags 2.6.0", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -2399,6 +2402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index 3a91d6f..84a493d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,7 @@ axum = { version = "0.7.7", features = ["macros", "multipart"] } axum-htmx = { version = "0.6.0", features = ["auto-vary"] } dotenvy = "0.15.7" oauth2 = "4.4.2" -sqlx = { version = "0.8.2", features = ["runtime-tokio", "sqlite"] } -time = { version = "0.3.36", features = ["parsing"] } +sqlx = { version = "0.8.2", default-features = false, features = ["runtime-tokio", "sqlite", "chrono", "macros"] } tokio = { version = "1.41.0", features = ["full", "tracing"] } tower = { version = "0.5.1", features = ["util"] } tower-http = { version = "0.6.1", features = ["fs", "trace"] } @@ -25,6 +24,7 @@ askama_axum = "0.4.0" axum-extra = "0.9.4" csv = "1.3.1" ron = "0.8.1" +chrono = { version = "0.4.38", features = ["serde"] } [dev-dependencies] httpc-test = "0.1.10" diff --git a/migrations/20241107225934_initial.sql b/migrations/20241107225934_initial.sql index bb3b26b..a2135d5 100644 --- a/migrations/20241107225934_initial.sql +++ b/migrations/20241107225934_initial.sql @@ -30,8 +30,8 @@ CREATE TABLE IF NOT EXISTS PositiveAdjustment ( id INTEGER PRIMARY KEY NOT NULL, item INTEGER NOT NULL, user INTEGER NOT NULL, - create_date TIMESTAMP NOT NULL, - target_date TIMESTAMP NOT NULL, + create_date DATETIME NOT NULL, + target_date DATETIME NOT NULL, amount REAL NOT NULL, unit_price INTEGER NOT NULL, FOREIGN KEY(user) REFERENCES User(id), @@ -42,8 +42,8 @@ CREATE TABLE IF NOT EXISTS NegativeAdjustment ( id INTEGER PRIMARY KEY NOT NULL, item INTEGER NOT NULL, user INTEGER NOT NULL, - create_date TIMESTAMP NOT NULL, - target_date TIMESTAMP NOT NULL, + create_date INTEGER NOT NULL, + target_date INTEGER NOT NULL, amount REAL NOT NULL, reason INTEGER NOT NULL, FOREIGN KEY(user) REFERENCES User(id), @@ -57,9 +57,11 @@ CREATE TABLE IF NOT EXISTS NegativeAdjustmentReason ( ); INSERT INTO NegativeAdjustmentReason (id, name) VALUES - (10,'Sale'), - (20,'Loss'), - (25,'Expired'); + (0,'unknown'), + (10,'sale'), + (20,'destruction'), + (25,'expiration'), + (30,'theft'); CREATE TABLE IF NOT EXISTS DisplayUnit ( id INTEGER PRIMARY KEY NOT NULL, diff --git a/src/app/audit.rs b/src/app/audit.rs deleted file mode 100644 index 49cc903..0000000 --- a/src/app/audit.rs +++ /dev/null @@ -1,12 +0,0 @@ -use askama::Template; -use askama_axum::{IntoResponse, Response}; -use crate::error::{AppError}; - -#[derive(Template)] -#[template(path = "audit.html")] -struct AuditLogTemplate; - - -pub async fn audit_log_handler() -> Result { - Ok(AuditLogTemplate.into_response()) -} diff --git a/src/app/history.rs b/src/app/history.rs new file mode 100644 index 0000000..a1e7127 --- /dev/null +++ b/src/app/history.rs @@ -0,0 +1,95 @@ +use askama::Template; +use askama_axum::{IntoResponse, Response}; +use axum::extract::State; +use axum_htmx::HxRequest; +use serde::Deserialize; +use sqlx::SqlitePool; +use tracing::info; +use chrono::prelude::*; +use crate::db::positive_adjustment::{get_positive_adjustments_target_date_range, DbPositiveAdjustment}; +use crate::error::{AppError, QueryExtractor}; + +#[derive(Template)] +#[template(path = "history.html")] +struct HistoryLogTemplate { + items: Vec, + start_date: String, + start_time: String, + end_date: String, + end_time: String, +} + +#[derive(Template)] +#[template(path = "history_item_fragment.html")] +struct HistoryLogItemFragmentTemplate { + items: Vec +} + +/// Common query args for datetime ranges +#[derive(Debug, Deserialize)] +pub struct DatetimeRangeQueryArgs { + #[serde(rename = "start-date", alias = "sd")] + pub start_date: Option, + #[serde(rename = "start-time", alias = "st")] + pub start_time: Option, + #[serde(rename = "end-date", alias = "ed")] + pub end_date: Option, + #[serde(rename = "end-time", alias = "et")] + pub end_time: Option, +} + +pub async fn history_log_handler( + QueryExtractor(query): QueryExtractor, + HxRequest(hx_request): HxRequest, + State(db): State +) -> Result { + + let today = Local::now().naive_local().date(); + + let start_date = query.start_date.unwrap_or("2000-01-01".to_string()); + let start_time = query.start_time.unwrap_or("00:00:00".to_string()); + let end_date = query.end_date.unwrap_or(today.to_string()); + let end_time = query.end_time.unwrap_or("11:59:59".to_string()); + let timezone = FixedOffset::west_opt(6 * 3600) + .ok_or(anyhow::anyhow!("Invalid timezone"))?; + + let naive_start_date = start_date.parse::()?; + let naive_start_time = start_time.parse::()?; + let naive_end_date = end_date.parse::()?; + let naive_end_time = end_time.parse::()?; + + let combined_start = naive_start_date + .and_time(naive_start_time) + .and_local_timezone(timezone) + .earliest() + .ok_or(anyhow::anyhow!("Invalid start"))? + .to_utc(); + + let combined_end = naive_end_date + .and_time(naive_end_time) + .and_local_timezone(timezone) + .latest() + .ok_or(anyhow::anyhow!("Invalid start"))? + .to_utc(); + + + info!("Get items from: {} to {}", combined_start, combined_end); + + let items = get_positive_adjustments_target_date_range(&db, + combined_start, combined_end).await?; + + info!("Item count: {}", items.len()); + + if hx_request { + Ok(HistoryLogItemFragmentTemplate {items}.into_response()) + } + else { + Ok(HistoryLogTemplate { + items, + start_date, + start_time, + end_date, + end_time, + }.into_response()) + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 0abc826..2c38255 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,7 +14,7 @@ pub mod catalog; mod home; mod overview; mod reports; -mod audit; +mod history; pub fn routes() -> Router { Router::new() @@ -31,7 +31,7 @@ pub fn routes() -> Router { .route("/upload/catalog", post(upload::catalog::catalog_import)) .route("/overview", get(overview::overview_handler)) .route("/reports", get(reports::reports_handler)) - .route("/audit", get(audit::audit_log_handler)) + .route("/history", get(history::history_log_handler)) // Ensure that all routes here require an authenticated user // whether explicitly asked or not .route_layer(from_extractor::()) diff --git a/src/db/bootstrap_data.rs b/src/db/bootstrap_data.rs index da22f44..c4e7954 100644 --- a/src/db/bootstrap_data.rs +++ b/src/db/bootstrap_data.rs @@ -1,4 +1,4 @@ -use crate::db::user::{add_user, get_user_by_name, DbUserRole}; +use crate::db::user::{add_user, get_user_by_name, get_user_count, DbUserRole}; use anyhow::{anyhow, Result}; use serde::Deserialize; use sqlx::SqlitePool; @@ -30,13 +30,22 @@ pub async fn bootstrap_database(db: &SqlitePool) -> Result<()> { let data = ron::from_str::(&bootstrap_str)?; - for user in &data.users { - let role = DbUserRole::try_from_str(&user.role).ok_or(anyhow!("invalid role {}", user.role))?; - if get_user_by_name(db, &user.name).await?.is_none() { - let new_id = add_user(db, &user.name, role).await?; - info!("bootstrap new user {}:{} ({})", new_id, user.name, user.role); + if db_needs_users(db).await? { + for user in &data.users { + let role = DbUserRole::try_from_str(&user.role).ok_or(anyhow!("invalid role {}", user.role))?; + if get_user_by_name(db, &user.name).await?.is_none() { + let new_id = add_user(db, &user.name, role).await?; + info!("bootstrap new user {}:{} ({})", new_id, user.name, user.role); + } } } + Ok(()) } + +async fn db_needs_users(db: &SqlitePool) -> Result { + let count = get_user_count(&db).await?; + + Ok(count <= 0) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 7f25780..b8455bb 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -2,12 +2,13 @@ pub mod inventory_item; pub mod positive_adjustment; mod bootstrap_data; pub mod user; +pub mod negative_adjustment; -use std::str::FromStr; +use crate::db::bootstrap_data::bootstrap_database; use anyhow::Context; -use sqlx::SqlitePool; use sqlx::sqlite::SqliteConnectOptions; -use crate::db::bootstrap_data::bootstrap_database; +use sqlx::SqlitePool; +use std::str::FromStr; pub async fn init() -> anyhow::Result { let db_url = std::env::var("DATABASE_URL") @@ -26,10 +27,17 @@ pub async fn connect_db(url: &str) -> anyhow::Result { let options = SqliteConnectOptions::from_str(url)? .create_if_missing(true); + + let exists = options.get_filename().exists(); let db = SqlitePool::connect_with(options).await?; - tracing::info!("Database connected {}", url); + if exists { + tracing::info!("Database connected {}", url); + } + else { + tracing::info!("New Database created {}", url); + } Ok(db) } diff --git a/src/db/negative_adjustment.rs b/src/db/negative_adjustment.rs new file mode 100644 index 0000000..0271a3c --- /dev/null +++ b/src/db/negative_adjustment.rs @@ -0,0 +1,115 @@ +use serde::Serialize; +use sqlx::SqlitePool; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use tracing::error; + +#[derive(Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbNegativeAdjustment { + pub id: i64, + pub item: i64, + pub user: i64, + pub create_date: i64, + pub target_date: i64, + pub amount: f64, + pub reason: DbNegativeAdjustmentReason, +} + +pub async fn add_negative_adjustment(db: &SqlitePool, item: i64, user: i64, + create_date: DateTime, target_date: DateTime, + amount: f64, reason: DbNegativeAdjustmentReason) -> Result { + let reason: i64 = reason.into(); + let res = sqlx::query!( + r#" + INSERT INTO NegativeAdjustment (item, user, create_date, target_date, amount, reason) + VALUES (?, ?, ?, ?, ?, ?) + "#, + item, user, create_date, target_date, amount, reason + ).execute(db).await?; + + let new_id = res.last_insert_rowid(); + + Ok(new_id) +} + +pub async fn get_negative_adjustments_target_date_range( + db: &SqlitePool, start_date: DateTime, end_date: DateTime +) -> Result> { + sqlx::query_as!( + DbNegativeAdjustment, + r#" + SELECT id, item, user, create_date, target_date, amount, reason + FROM NegativeAdjustment + WHERE target_date >= ? AND target_date <= ? + "#, + start_date, end_date + ) + .fetch_all(db) + .await.map_err(Into::into) +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub enum DbNegativeAdjustmentReason { + Unknown, + Sale, + Destruction, + Expiration, + Theft, +} + +impl Into for DbNegativeAdjustmentReason { + fn into(self) -> i64 { + match self { + Self::Unknown => 0, + Self::Sale => 10, + Self::Destruction => 20, + Self::Expiration => 25, + Self::Theft => 30, + } + } +} + +impl From for DbNegativeAdjustmentReason { + fn from(item: i64) -> Self { + match item { + 0 => Self::Unknown, + 10 => Self::Sale, + 20 => Self::Destruction, + 25 => Self::Expiration, + 30 => Self::Theft, + _ => { + error!("unknown negative adjustment reason value: {}", item); + Self::Unknown + } + } + } +} + +impl TryFrom<&str> for DbNegativeAdjustmentReason { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + match value { + "unknown" => Ok(Self::Unknown), + "sale" => Ok(Self::Sale), + "destruction" => Ok(Self::Destruction), + "expiration" => Ok(Self::Expiration), + "theft" => Ok(Self::Theft), + _ => Err(anyhow::anyhow!("unknown negative adjustment reason")) + } + } +} + +impl Into for DbNegativeAdjustmentReason { + fn into(self) -> String { + match self { + Self::Unknown => { String::from("unknown") } + Self::Sale => { String::from("sale") } + Self::Destruction => { String::from("destruction") } + Self::Expiration => { String::from("expiration") } + Self::Theft => { String::from("theft") } + } + } +} + diff --git a/src/db/positive_adjustment.rs b/src/db/positive_adjustment.rs index 8e6af3e..9667f65 100644 --- a/src/db/positive_adjustment.rs +++ b/src/db/positive_adjustment.rs @@ -1,6 +1,7 @@ use serde::Serialize; -use sqlx::SqlitePool; use anyhow::Result; +use chrono::{DateTime, Utc}; +use sqlx::SqlitePool; #[derive(Debug, Serialize)] #[derive(sqlx::FromRow)] @@ -8,14 +9,14 @@ pub struct DbPositiveAdjustment { pub id: i64, pub item: i64, pub user: i64, - pub create_date: String, - pub target_date: String, + pub create_date: DateTime, + pub target_date: DateTime, pub amount: f64, pub unit_price: i64, } pub async fn add_positive_adjustment(db: &SqlitePool, item: i64, user: i64, - create_date: &str, target_date: &str, + create_date: DateTime, target_date: DateTime, amount: f64, unit_price: i64) -> Result { let res = sqlx::query!( @@ -30,3 +31,23 @@ pub async fn add_positive_adjustment(db: &SqlitePool, item: i64, user: i64, Ok(new_id) } + +pub async fn get_positive_adjustments_target_date_range( + db: &SqlitePool, start_date: DateTime, end_date: DateTime +) -> Result> { + + sqlx::query_as::<_, DbPositiveAdjustment>(r#" + SELECT id, item, user, + create_date, + target_date, + amount, unit_price + FROM PositiveAdjustment + WHERE target_date >= ? AND target_date <= ? + "#,) + .bind(start_date) + .bind(end_date) + .fetch_all(db) + .await.map_err(Into::into) +} + + diff --git a/src/db/user.rs b/src/db/user.rs index d7841ad..a15d781 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -43,6 +43,16 @@ pub async fn add_user(db: &SqlitePool, name: &str, role: DbUserRole) -> Result Result { + let res = sqlx::query!( + r#" + SELECT COUNT(1) AS user_count FROM User + "#, + ).fetch_one(db).await?; + + Ok(res.user_count) +} + #[derive(Debug, Clone, Copy)] pub enum DbUserRole { diff --git a/src/ingest.rs b/src/ingest.rs index 3e4e5b0..0529437 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -2,7 +2,6 @@ use anyhow::Result; use axum::body::Bytes; use sqlx::SqlitePool; -use time::format_description::well_known::Iso8601; use tracing::info; use crate::db::inventory_item::add_inventory_item; use crate::db::positive_adjustment::add_positive_adjustment; @@ -28,8 +27,7 @@ pub async fn ingest_catalog_bytes(bytes: Bytes, db: SqlitePool, user_id: i64) -> pub async fn ingest_catalog(mut reader: csv::Reader, db: SqlitePool, user_id: i64) -> Result<()> { - //TODO Is this how we want to do dates? - let timestamp = time::OffsetDateTime::now_utc().format(&Iso8601::DEFAULT)?; + let timestamp = chrono::Utc::now(); for result in reader.deserialize() { let record: CatalogRecord = result?; @@ -41,7 +39,7 @@ pub async fn ingest_catalog(mut reader: csv::Reader, db: Sq &record.unit).await?; let new_positive_adjustment = add_positive_adjustment(&db, new_entry_id, - user_id, ×tamp, ×tamp, record.quantity, record.unit_price).await?; + user_id, timestamp, timestamp, record.quantity, record.unit_price).await?; info!("Added new item: {}/{} - {}", new_entry_id, new_positive_adjustment, record.name); } diff --git a/src/session.rs b/src/session.rs index 7d692c3..ba44933 100644 --- a/src/session.rs +++ b/src/session.rs @@ -8,10 +8,10 @@ use axum::http::request::Parts; use axum::response::Redirect; use serde::{Deserialize, Serialize}; use std::result; -use time::Duration; use tokio::task::JoinHandle; use tower_sessions::cookie::SameSite; use tower_sessions::{ExpiredDeletion, Expiry, Session, SessionManagerLayer}; +use tower_sessions::cookie::time::Duration; use tower_sessions_sqlx_store::SqliteStore; pub async fn init() -> Result<(SessionManagerLayer, JoinHandle>)> { diff --git a/templates/audit.html b/templates/audit.html deleted file mode 100644 index 4081a90..0000000 --- a/templates/audit.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "main.html" %} - -{% block title %} Audit Log {% endblock %} - -{% block content %} - -

Audit Log (Coming soon)

- -{% endblock %} \ No newline at end of file diff --git a/templates/history.html b/templates/history.html new file mode 100644 index 0000000..1bc141e --- /dev/null +++ b/templates/history.html @@ -0,0 +1,37 @@ +{% extends "main.html" %} + +{% block title %} Audit Log {% endblock %} + +{% block content %} + +

Audit Log (Coming soon)

+ +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ {% include "history_item_fragment.html" %} +
+ +{% endblock %} \ No newline at end of file diff --git a/templates/history_item_fragment.html b/templates/history_item_fragment.html new file mode 100644 index 0000000..4ad584c --- /dev/null +++ b/templates/history_item_fragment.html @@ -0,0 +1,9 @@ + +{% for item in items %} +
+
+

{{ item.item }}

+

{{ item.amount }}

+
+
+{% endfor %} \ No newline at end of file diff --git a/templates/main.html b/templates/main.html index 9cb7a05..dafc015 100644 --- a/templates/main.html +++ b/templates/main.html @@ -4,8 +4,10 @@ - + + + {% block title %}Title{% endblock %} @@ -19,7 +21,7 @@
  • Catalog
  • Upload
  • Reports
  • -
  • Audit
  • +
  • History
  • Logout
  • diff --git a/templates/upload.html b/templates/upload.html index 684c633..5869c28 100644 --- a/templates/upload.html +++ b/templates/upload.html @@ -4,11 +4,15 @@ {% block content %} -
    -

    Catalog Import

    -
    - - + +
    +

    Catalog Import

    + + +