parent
dfd7a9b6a8
commit
949b6b0238
@ -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
|
||||||
|
);
|
||||||
|
|
||||||
@ -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<DbInventoryItem>,
|
||||||
|
query: ItemsQueryArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "item_list_fragment.html")]
|
||||||
|
struct ItemListFragmentTemplate {
|
||||||
|
items: Vec<DbInventoryItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query string response for "authorized" endpoint
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ItemsQueryArgs {
|
||||||
|
#[serde(rename = "q")]
|
||||||
|
pub search: Option<String>,
|
||||||
|
#[serde(alias = "p")]
|
||||||
|
pub page: Option<i64>,
|
||||||
|
#[serde(rename = "size")]
|
||||||
|
pub page_size: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn item_list(
|
||||||
|
QueryExtractor(query): QueryExtractor<ItemsQueryArgs>,
|
||||||
|
HxRequest(hx_request): HxRequest,
|
||||||
|
State(db): State<SqlitePool>
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
|
||||||
|
let item = inventory_item_get_by_id(&db, id).await?;
|
||||||
|
|
||||||
|
Ok(ItemTemplate { item }.into_response())
|
||||||
|
}
|
||||||
@ -1,27 +1,24 @@
|
|||||||
use askama::Template;
|
|
||||||
use askama_axum::IntoResponse;
|
|
||||||
use axum::middleware::from_extractor;
|
use axum::middleware::from_extractor;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
|
use axum_htmx::{AutoVaryLayer};
|
||||||
use crate::app::state::AppState;
|
use crate::app::state::AppState;
|
||||||
use crate::auth::User;
|
use crate::auth::User;
|
||||||
|
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod items;
|
||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(items::item_list))
|
||||||
.route("/index.html", get(index))
|
.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
|
// Ensure that all routes here require an authenticated user
|
||||||
// whether explicitly asked or not
|
// whether explicitly asked or not
|
||||||
.route_layer(from_extractor::<User>())
|
.route_layer(from_extractor::<User>())
|
||||||
}
|
.layer(AutoVaryLayer)
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "index.html")]
|
|
||||||
struct IndexTemplate;
|
|
||||||
|
|
||||||
async fn index() -> impl IntoResponse {
|
|
||||||
IndexTemplate.into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
use sqlx::SqlitePool;
|
|
||||||
use sqlx::sqlite::SqliteConnectOptions;
|
|
||||||
|
|
||||||
pub async fn init(filename: &str) -> anyhow::Result<SqlitePool> {
|
|
||||||
let options = SqliteConnectOptions::new()
|
|
||||||
.filename(filename)
|
|
||||||
.create_if_missing(true);
|
|
||||||
|
|
||||||
let db = SqlitePool::connect_with(options).await?;
|
|
||||||
|
|
||||||
tracing::info!("Database connected {}", filename);
|
|
||||||
|
|
||||||
Ok(db)
|
|
||||||
}
|
|
||||||
@ -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<Vec<DbInventoryItem>> {
|
||||||
|
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<Vec<DbInventoryItem>> {
|
||||||
|
|
||||||
|
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<DbInventoryItem> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbInventoryItem,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id, name, reorder_point
|
||||||
|
FROM
|
||||||
|
InventoryItem
|
||||||
|
WHERE id = ?
|
||||||
|
"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_one(db).await
|
||||||
|
.map_err(From::from)
|
||||||
|
}
|
||||||
@ -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<SqlitePool> {
|
||||||
|
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<SqlitePool> {
|
||||||
|
|
||||||
|
let options = SqliteConnectOptions::from_str(url)?
|
||||||
|
.create_if_missing(true);
|
||||||
|
|
||||||
|
let db = SqlitePool::connect_with(options).await?;
|
||||||
|
|
||||||
|
tracing::info!("Database connected {}", url);
|
||||||
|
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
{% extends "main.html" %}
|
|
||||||
|
|
||||||
{% block title %} Inventory App {% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<input name="search"
|
|
||||||
placeholder="Search"
|
|
||||||
aria-label="Search"
|
|
||||||
type="search">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
{% extends "main.html" %}
|
||||||
|
|
||||||
|
{% block title %} Items {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h3>{{item.name}}</h3>
|
||||||
|
<p>Reorder at: {{item.reorder_point}}</p>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
{% extends "main.html" %}
|
||||||
|
|
||||||
|
{% block title %} Items {% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<input id="search" type="search" name="q"
|
||||||
|
placeholder="Search"
|
||||||
|
aria-label="Search"
|
||||||
|
value='{{ query.search.as_deref().unwrap_or("") }}'
|
||||||
|
hx-get="/items"
|
||||||
|
hx-trigger="search, keyup delay:200ms changed"
|
||||||
|
hx-target="#items"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="items" class="container">
|
||||||
|
{% include "item_list_fragment.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
{% for item in items %}
|
||||||
|
<article>
|
||||||
|
<div class="grid">
|
||||||
|
<div><a href="/item/{{item.id}}/">{{ item.name }}</a></div>
|
||||||
|
<div>Reorder Point: {{ item.reorder_point }}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
Loading…
Reference in new issue