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 @@