Switch datetime library from time to chrono

demo-mode
Wes Holland 1 year ago
parent 12d8bc0e21
commit 5c0e250a36

@ -0,0 +1,17 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myValues">
<value>
<list size="3">
<item index="0" class="java.lang.String" itemvalue="x-show" />
<item index="1" class="java.lang.String" itemvalue="x-model" />
<item index="2" class="java.lang.String" itemvalue="x-data" />
</list>
</value>
</option>
<option name="myCustomValuesEnabled" value="true" />
</inspection_tool>
</profile>
</component>

@ -8,5 +8,6 @@
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="alpinejs" level="application" />
</component>
</module>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="file://$PROJECT_DIR$" libraries="{alpinejs}" />
</component>
</project>

6
Cargo.lock generated

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

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

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

@ -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<Response, AppError> {
Ok(AuditLogTemplate.into_response())
}

@ -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<DbPositiveAdjustment>,
start_date: String,
start_time: String,
end_date: String,
end_time: String,
}
#[derive(Template)]
#[template(path = "history_item_fragment.html")]
struct HistoryLogItemFragmentTemplate {
items: Vec<DbPositiveAdjustment>
}
/// Common query args for datetime ranges
#[derive(Debug, Deserialize)]
pub struct DatetimeRangeQueryArgs {
#[serde(rename = "start-date", alias = "sd")]
pub start_date: Option<String>,
#[serde(rename = "start-time", alias = "st")]
pub start_time: Option<String>,
#[serde(rename = "end-date", alias = "ed")]
pub end_date: Option<String>,
#[serde(rename = "end-time", alias = "et")]
pub end_time: Option<String>,
}
pub async fn history_log_handler(
QueryExtractor(query): QueryExtractor<DatetimeRangeQueryArgs>,
HxRequest(hx_request): HxRequest,
State(db): State<SqlitePool>
) -> Result<Response, AppError> {
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::<NaiveDate>()?;
let naive_start_time = start_time.parse::<NaiveTime>()?;
let naive_end_date = end_date.parse::<NaiveDate>()?;
let naive_end_time = end_time.parse::<NaiveTime>()?;
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())
}
}

@ -14,7 +14,7 @@ pub mod catalog;
mod home;
mod overview;
mod reports;
mod audit;
mod history;
pub fn routes() -> Router<AppState> {
Router::new()
@ -31,7 +31,7 @@ pub fn routes() -> Router<AppState> {
.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::<SessionUser>())

@ -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::<BootstrapData>(&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<bool> {
let count = get_user_count(&db).await?;
Ok(count <= 0)
}

@ -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<SqlitePool> {
let db_url = std::env::var("DATABASE_URL")
@ -26,10 +27,17 @@ pub async fn connect_db(url: &str) -> anyhow::Result<SqlitePool> {
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)
}

@ -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<Utc>, target_date: DateTime<Utc>,
amount: f64, reason: DbNegativeAdjustmentReason) -> Result<i64> {
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<Utc>, end_date: DateTime<Utc>
) -> Result<Vec<DbNegativeAdjustment>> {
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<i64> 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<i64> 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<Self, Self::Error> {
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<String> 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") }
}
}
}

@ -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<Utc>,
pub target_date: DateTime<Utc>,
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<Utc>, target_date: DateTime<Utc>,
amount: f64, unit_price: i64)
-> Result<i64> {
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<Utc>, end_date: DateTime<Utc>
) -> Result<Vec<DbPositiveAdjustment>> {
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)
}

@ -43,6 +43,16 @@ pub async fn add_user(db: &SqlitePool, name: &str, role: DbUserRole) -> Result<i
Ok(new_id)
}
pub async fn get_user_count(db: &SqlitePool) -> Result<i64> {
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 {

@ -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<T: std::io::Read>(mut reader: csv::Reader<T>, 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<T: std::io::Read>(mut reader: csv::Reader<T>, db: Sq
&record.unit).await?;
let new_positive_adjustment = add_positive_adjustment(&db, new_entry_id,
user_id, &timestamp, &timestamp, 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);
}

@ -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<SqliteStore>, JoinHandle<Result<()>>)> {

@ -1,9 +0,0 @@
{% extends "main.html" %}
{% block title %} Audit Log {% endblock %}
{% block content %}
<h1>Audit Log (Coming soon)</h1>
{% endblock %}

@ -0,0 +1,37 @@
{% extends "main.html" %}
{% block title %} Audit Log {% endblock %}
{% block content %}
<h1>Audit Log (Coming soon)</h1>
<form action="/history" hx-get="/history" hx-trigger="change" hx-target="#items">
<div class="grid">
<fieldset>
<label for="start-date">Start Date</label>
<input type="date" id="start-date" name="start" value="{{ start_date }}" />
</fieldset>
<fieldset>
<label for="start-time">Start Time</label>
<input type="time" id="start-time" name="start" value="{{ start_time }}"/>
</fieldset>
</div>
<div class="grid">
<fieldset>
<label for="end-date">End Date</label>
<input type="date" id="end-date" name="end" value="{{ end_date }}"/>
</fieldset>
<fieldset>
<label for="end-time">End Time</label>
<input type="time" id="end-time" name="end" value="{{ end_time }}"/>
</fieldset>
</div>
</form>
<div id="items" class="container">
{% include "history_item_fragment.html" %}
</div>
{% endblock %}

@ -0,0 +1,9 @@
{% for item in items %}
<article id="item-{{item.id}}-card">
<div class="grid">
<p>{{ item.item }}</p>
<p>{{ item.amount }}</p>
</div>
</article>
{% endfor %}

@ -4,8 +4,10 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="stylesheet" href="/css/pico.min.css">
<link rel="stylesheet" href="/css/pico.min.css" >
<script src="/js/htmx.min.js"></script>
<!--TODO Vendor this script -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<title>{% block title %}Title{% endblock %}</title>
</head>
<body>
@ -19,7 +21,7 @@
<li><a class="secondary" href="/catalog">Catalog</a></li>
<li><a class="secondary" href="/upload">Upload</a></li>
<li><a class="secondary" href="/reports">Reports</a></li>
<li><a class="secondary" href="/audit">Audit</a></li>
<li><a class="secondary" href="/history">History</a></li>
<li><a class="contrast" href="/auth/logout">Logout</a></li>
</ul>
</nav>

@ -4,11 +4,15 @@
{% block content %}
<form action="/upload/catalog" method="post" enctype="multipart/form-data">
<h3>Catalog Import</h3>
<fieldset role="group">
<input type="file" name="file" />
<input type="submit" value="Upload">
<form action="/upload/catalog" method="post" enctype="multipart/form-data" x-data="{ file: '' }">
<fieldset class="grid">
<h3>Catalog Import</h3>
<label role="button" class="secondary" x-show="!file">
Choose File
<input type="file" name="file" x-model="file" style="display: none" required />
</label>
<input type="submit" value="Upload" x-show="file">
<input type="reset" value="Cancel" x-show="file">
</fieldset>
</form>

Loading…
Cancel
Save

Powered by TurnKey Linux.