From 949b6b023815d2c6d1ae56d87582c168d401dd92 Mon Sep 17 00:00:00 2001 From: Wes Holland Date: Sat, 9 Nov 2024 11:21:14 -0600 Subject: [PATCH] Database setup and first queries --- Cargo.lock | 19 ++++++-- Cargo.toml | 2 +- example.env | 4 +- migrations/20241107225934_initial.sql | 41 ++++++++++++++++ src/app/items.rs | 67 ++++++++++++++++++++++++++ src/app/mod.rs | 21 ++++---- src/db.rs | 14 ------ src/db/inventory_item.rs | 69 +++++++++++++++++++++++++++ src/db/mod.rs | 30 ++++++++++++ src/main.rs | 4 +- src/session.rs | 6 +-- templates/index.html | 14 ------ templates/item.html | 10 ++++ templates/item_list.html | 22 +++++++++ templates/item_list_fragment.html | 9 ++++ templates/main.html | 2 +- 16 files changed, 281 insertions(+), 53 deletions(-) create mode 100644 migrations/20241107225934_initial.sql create mode 100644 src/app/items.rs delete mode 100644 src/db.rs create mode 100644 src/db/inventory_item.rs create mode 100644 src/db/mod.rs delete mode 100644 templates/index.html create mode 100644 templates/item.html create mode 100644 templates/item_list.html create mode 100644 templates/item_list_fragment.html diff --git a/Cargo.lock b/Cargo.lock index b451388..7fd5bd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,7 +181,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.1", "tokio", - "tower", + "tower 0.5.1", "tower-layer", "tower-service", "tracing", @@ -224,7 +224,7 @@ dependencies = [ "mime", "pin-project-lite", "serde", - "tower", + "tower 0.5.1", "tower-layer", "tower-service", "tracing", @@ -238,7 +238,10 @@ checksum = "36cdb6062317f732ed3acf4e9c28c3824092e226726616f46ebdd8cd32c82a41" dependencies = [ "async-trait", "axum-core", + "futures", "http 1.1.0", + "tokio", + "tower 0.4.13", ] [[package]] @@ -1137,7 +1140,7 @@ dependencies = [ "sqlx", "time", "tokio", - "tower", + "tower 0.5.1", "tower-http", "tower-sessions", "tower-sessions-sqlx-store", @@ -2619,6 +2622,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", +] + [[package]] name = "tower" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index e5a4636..b393e5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" anyhow = "1.0.91" askama = { version = "0.12.1", features = ["with-axum"] } axum = { version = "0.7.7", features = ["macros"] } -axum-htmx = "0.6.0" +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"] } diff --git a/example.env b/example.env index d0b388a..530e5ba 100644 --- a/example.env +++ b/example.env @@ -1,7 +1,7 @@ # Copy this to .env and change OAUTH Values RUST_LOG=debug,tower_http=info -DATABASE_URI=inventory-app.db -SESSION_DATABASE_URI=session.db +DATABASE_URL=sqlite://inventory-app.db +SESSION_DATABASE_URL=sqlite://session.db OAUTH_CLIENT_ID=changeme OAUTH_CLIENT_SECRET=changme OAUTH_AUTH_URL=https://accounts.google.com/o/oauth2/auth diff --git a/migrations/20241107225934_initial.sql b/migrations/20241107225934_initial.sql new file mode 100644 index 0000000..e9bd9ef --- /dev/null +++ b/migrations/20241107225934_initial.sql @@ -0,0 +1,41 @@ +-- Add migration script here + +CREATE TABLE IF NOT EXISTS User ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL, + CHECK(role = 'admin' OR role = 'editor' OR role = 'viewer') +); + +CREATE TABLE IF NOT EXISTS InventoryItem ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + reorder_point INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS PositiveAdjustment ( + id INTEGER PRIMARY KEY NOT NULL, + user INTEGER NOT NULL, + create_date TIMESTAMP NOT NULL, + target_date TIMESTAMP NOT NULL, + amount INTEGER NOT NULL, + unit_price INTEGER NOT NULL, + FOREIGN KEY(user) REFERENCES User(id) +); + +CREATE TABLE IF NOT EXISTS NegativeAdjustment ( + id INTEGER PRIMARY KEY NOT NULL, + user INTEGER NOT NULL, + create_date TIMESTAMP NOT NULL, + target_date TIMESTAMP NOT NULL, + amount INTEGER NOT NULL, + reason INTEGER NOT NULL, + FOREIGN KEY(user) REFERENCES User(id), + FOREIGN KEY(reason) REFERENCES NegativeAdjustmentReason(id) +); + +CREATE TABLE IF NOT EXISTS NegativeAdjustmentReason ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL +); + diff --git a/src/app/items.rs b/src/app/items.rs new file mode 100644 index 0000000..dd9930b --- /dev/null +++ b/src/app/items.rs @@ -0,0 +1,67 @@ +use askama::Template; +use askama_axum::IntoResponse; +use axum::extract::{Path, State}; +use sqlx::SqlitePool; +use axum::response::Response; +use axum_htmx::HxRequest; +use serde::Deserialize; +use crate::db::inventory_item::{inventory_item_get_all, inventory_item_get_by_id, inventory_item_get_search, DbInventoryItem}; +use crate::error::{AppError, QueryExtractor}; + +#[derive(Template)] +#[template(path = "item_list.html")] +struct ItemListTemplate { + items: Vec, + query: ItemsQueryArgs, +} + +#[derive(Template)] +#[template(path = "item_list_fragment.html")] +struct ItemListFragmentTemplate { + items: Vec +} + +/// Query string response for "authorized" endpoint +#[derive(Debug, Deserialize)] +pub struct ItemsQueryArgs { + #[serde(rename = "q")] + pub search: Option, + #[serde(alias = "p")] + pub page: Option, + #[serde(rename = "size")] + pub page_size: Option, +} + +#[axum::debug_handler] +pub async fn item_list( + QueryExtractor(query): QueryExtractor, + HxRequest(hx_request): HxRequest, + State(db): State +) -> Result { + let page = query.page.unwrap_or(0); + let page_size = query.page_size.unwrap_or(100); + + let items = match query.search.as_ref() { + Some(s) => inventory_item_get_search(&db, &s, page_size, page).await?, + None => inventory_item_get_all(&db, page_size, page).await?, + }; + + if hx_request { + Ok(ItemListFragmentTemplate { items }.into_response()) + } + else { + Ok(ItemListTemplate { items, query }.into_response()) + } +} + +#[derive(Template)] +#[template(path = "item.html")] +struct ItemTemplate { + item: DbInventoryItem +} + +pub async fn item(State(db): State, Path(id): Path) -> Result { + let item = inventory_item_get_by_id(&db, id).await?; + + Ok(ItemTemplate { item }.into_response()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index d0ef9cf..fdcfee2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,27 +1,24 @@ -use askama::Template; -use askama_axum::IntoResponse; use axum::middleware::from_extractor; use axum::Router; use axum::routing::get; +use axum_htmx::{AutoVaryLayer}; use crate::app::state::AppState; use crate::auth::User; pub mod state; +pub mod items; pub fn routes() -> Router { Router::new() - .route("/", get(index)) - .route("/index.html", get(index)) + .route("/", get(items::item_list)) + .route("/index.html", get(items::item_list)) + .route("/items", get(items::item_list)) + .route("/items/", get(items::item_list)) + .route("/item/:item_id", get(items::item)) + .route("/item/:item_id/", get(items::item)) // Ensure that all routes here require an authenticated user // whether explicitly asked or not .route_layer(from_extractor::()) -} - -#[derive(Template)] -#[template(path = "index.html")] -struct IndexTemplate; - -async fn index() -> impl IntoResponse { - IndexTemplate.into_response() + .layer(AutoVaryLayer) } diff --git a/src/db.rs b/src/db.rs deleted file mode 100644 index 2b6ed48..0000000 --- a/src/db.rs +++ /dev/null @@ -1,14 +0,0 @@ -use sqlx::SqlitePool; -use sqlx::sqlite::SqliteConnectOptions; - -pub async fn init(filename: &str) -> anyhow::Result { - let options = SqliteConnectOptions::new() - .filename(filename) - .create_if_missing(true); - - let db = SqlitePool::connect_with(options).await?; - - tracing::info!("Database connected {}", filename); - - Ok(db) -} \ No newline at end of file diff --git a/src/db/inventory_item.rs b/src/db/inventory_item.rs new file mode 100644 index 0000000..0aa5be4 --- /dev/null +++ b/src/db/inventory_item.rs @@ -0,0 +1,69 @@ +use serde::Serialize; +use anyhow::Result; +use sqlx::SqlitePool; + +#[derive(Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbInventoryItem { + pub id: i64, + pub name: String, + pub reorder_point: i64, +} + + +pub async fn inventory_item_get_all(db: &SqlitePool, page_size: i64, page_num: i64) -> Result> { + let offset = page_num * page_size; + sqlx::query_as!( + DbInventoryItem, + r#" + SELECT + id, name, reorder_point + FROM + InventoryItem + LIMIT ? OFFSET ? + "#, + page_size, offset + ) + .fetch_all(db).await + .map_err(From::from) +} + +pub async fn inventory_item_get_search(db: &SqlitePool, + search_term: &str, + page_size: i64, + page_num: i64) -> Result> { + + let offset = page_num * page_size; + let search = String::from("%") + search_term + "%"; + sqlx::query_as!( + DbInventoryItem, + r#" + SELECT + id, name, reorder_point + FROM + InventoryItem + WHERE InventoryItem.name LIKE ? + LIMIT ? OFFSET ? + "#, + search, + page_size, offset + ) + .fetch_all(db).await + .map_err(From::from) +} + +pub async fn inventory_item_get_by_id(db: &SqlitePool, id: i64) -> Result { + sqlx::query_as!( + DbInventoryItem, + r#" + SELECT + id, name, reorder_point + FROM + InventoryItem + WHERE id = ? + "#, + id + ) + .fetch_one(db).await + .map_err(From::from) +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..d97d80b --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,30 @@ +pub mod inventory_item; + +use std::str::FromStr; +use anyhow::Context; +use sqlx::SqlitePool; +use sqlx::sqlite::SqliteConnectOptions; + +pub async fn init() -> anyhow::Result { + let db_url = std::env::var("DATABASE_URL") + .context("DATABASE_URI not set")?; + + let db = connect_db(&db_url).await?; + + sqlx::migrate!().run(&db).await?; + + Ok(db) +} + +pub async fn connect_db(url: &str) -> anyhow::Result { + + let options = SqliteConnectOptions::from_str(url)? + .create_if_missing(true); + + let db = SqlitePool::connect_with(options).await?; + + tracing::info!("Database connected {}", url); + + Ok(db) +} + diff --git a/src/main.rs b/src/main.rs index fc4c7d8..8cd0a07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,9 +46,7 @@ async fn main() -> Result<()>{ // Application database. What you would expect. Access // through the application state - let db_file = std::env::var("DATABASE_URI") - .context("DATABASE_URI not set")?; - let db = db::init(&db_file).await?; + let db = db::init().await?; // OAUTH2 Client let oauth_client = auth::init_client()?; diff --git a/src/session.rs b/src/session.rs index 425673e..e60b0c1 100644 --- a/src/session.rs +++ b/src/session.rs @@ -9,9 +9,9 @@ use crate::db; pub async fn init() -> Result<(SessionManagerLayer, JoinHandle>)> { // Session store is a session aware database backing for the session data - let session_db_location = std::env::var("SESSION_DATABASE_URI") - .context("SESSION_DATABASE_URI not set")?; - let session_db = db::init(&session_db_location).await?; + let session_db_location = std::env::var("SESSION_DATABASE_URL") + .context("SESSION_DATABASE_URL not set")?; + let session_db = db::connect_db(&session_db_location).await?; let session_store = SqliteStore::new(session_db); session_store.migrate().await?; diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index b6ca88b..0000000 --- a/templates/index.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "main.html" %} - -{% block title %} Inventory App {% endblock %} - -{% block content %} - -

- -

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

{{item.name}}

+

Reorder at: {{item.reorder_point}}

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

+ +

+ +
+ {% include "item_list_fragment.html" %} +
+ +{% endblock %} \ No newline at end of file diff --git a/templates/item_list_fragment.html b/templates/item_list_fragment.html new file mode 100644 index 0000000..f853b72 --- /dev/null +++ b/templates/item_list_fragment.html @@ -0,0 +1,9 @@ + +{% for item in items %} + +{% endfor %} \ No newline at end of file diff --git a/templates/main.html b/templates/main.html index 448b59b..ffd4be0 100644 --- a/templates/main.html +++ b/templates/main.html @@ -5,7 +5,6 @@ - {% block title %}Title{% endblock %} @@ -17,6 +16,7 @@