Store inventory details

This commit is contained in:
Jeremy Wall 2022-11-11 17:35:10 -05:00
parent a5e8575ef9
commit 60a1945fe8
13 changed files with 412 additions and 18 deletions

View File

@ -0,0 +1,3 @@
-- Add down migration script here
delete table filtered_ingredients;
delete table modified_amts;

View File

@ -0,0 +1,17 @@
-- Add up migration script here
create table filtered_ingredients(
user_id TEXT NOT NULL,
name TEXT NOT NULL,
form TEXT NOT NULL,
measure_type TEXT NOT NULL,
primary key(user_id, name, form, measure_type)
);
create table modified_amts(
user_id TEXT NOT NULL,
name TEXT NOT NULL,
form TEXT NOT NULL,
measure_type TEXT NOT NULL,
amt TEXT NOT NULL,
primary key(user_id, name, form, measure_type)
);

View File

@ -1,5 +1,15 @@
{
"db": "SQLite",
"0e06f6e072e2c55769feda0d5f998509139097fee640caf8fb38c7087669bee4": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 4
}
},
"query": "insert into filtered_ingredients(user_id, name, form, measure_type)\n values (?, ?, ?, ?)"
},
"104f07472670436d3eee1733578bbf0c92dc4f965d3d13f9bf4bfbc92958c5b6": {
"describe": {
"columns": [
@ -48,7 +58,7 @@
{
"name": "plan_date: NaiveDate",
"ordinal": 0,
"type_info": "Text"
"type_info": "Date"
},
{
"name": "recipe_id",
@ -82,6 +92,16 @@
},
"query": "insert into recipes (user_id, recipe_id, recipe_text) values (?, ?, ?)\n on conflict(user_id, recipe_id) do update set recipe_text=excluded.recipe_text"
},
"512003bd6ef47567b243bbcaefcbd220acf36efab4cbd43b1a8debe59593577c": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 5
}
},
"query": "insert into modified_amts(user_id, name, form, measure_type, amt)\n values (?, ?, ?, ?, ?)"
},
"5d743897fb0d8fd54c3708f1b1c6e416346201faa9e28823c1ba5a421472b1fa": {
"describe": {
"columns": [],
@ -170,7 +190,7 @@
{
"name": "plan_date: NaiveDate",
"ordinal": 0,
"type_info": "Text"
"type_info": "Date"
},
{
"name": "recipe_id",
@ -222,6 +242,36 @@
},
"query": "select category_text from categories where user_id = ?"
},
"d7d94a87b0153d1436eac0f6db820f25594e94decc8d740037c10802aa49157f": {
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "form",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "measure_type",
"ordinal": 2,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "select name, form, measure_type from filtered_ingredients where user_id = ?"
},
"d84685a82585c5e4ae72c86ba1fe6e4a7241c4c3c9e948213e5849d956132bad": {
"describe": {
"columns": [],
@ -231,5 +281,41 @@
}
},
"query": "delete from sessions"
},
"fc294739374d2a791214f747095e0bf9378989d1ff07d96a5431dbb208f21951": {
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "form",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "measure_type",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "amt",
"ordinal": 3,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "select name, form, measure_type, amt from modified_amts where user_id = ?;"
}
}

View File

@ -1,3 +1,4 @@
use std::collections::BTreeMap;
// Copyright 2022 Jeremy Wall
//
// Licensed under the Apache License, Version 2.0 (the "License");
@ -11,9 +12,9 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::{collections::BTreeSet, net::SocketAddr};
use axum::{
body::{boxed, Full},
@ -23,11 +24,11 @@ use axum::{
routing::{get, Router},
};
use mime_guess;
use recipes::RecipeEntry;
use recipes::{IngredientKey, RecipeEntry};
use rust_embed::RustEmbed;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use tracing::{debug, info, instrument};
use tracing::{debug, error, info, instrument};
use storage::{APIStore, AuthStore};
@ -263,6 +264,56 @@ async fn api_save_plan(
}
}
async fn api_inventory(
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
) -> impl IntoResponse {
use storage::{UserId, UserIdFromSession::FoundUserId};
if let FoundUserId(UserId(id)) = session {
match app_store.fetch_inventory_data(id).await {
Ok(tpl) => Ok(axum::Json::from(tpl)),
Err(e) => {
error!(err=?e);
Err((StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))
}
}
} else {
Err((
StatusCode::UNAUTHORIZED,
"You must be authorized to use this API call".to_owned(),
))
}
}
async fn api_save_inventory(
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Json((filtered_ingredients, modified_amts)): Json<(
BTreeSet<IngredientKey>,
BTreeMap<IngredientKey, String>,
)>,
) -> impl IntoResponse {
use storage::{UserId, UserIdFromSession::FoundUserId};
if let FoundUserId(UserId(id)) = session {
if let Err(e) = app_store
.save_inventory_data(id, filtered_ingredients, modified_amts)
.await
{
error!(err=?e);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e));
}
(
StatusCode::OK,
"Successfully saved inventory data".to_owned(),
)
} else {
(
StatusCode::UNAUTHORIZED,
"You must be authorized to use this API call".to_owned(),
)
}
}
#[instrument(fields(recipe_dir=?recipe_dir_path,listen=?listen_socket), skip_all)]
pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socket: SocketAddr) {
let store = Arc::new(storage::file_store::AsyncFileStore::new(
@ -281,9 +332,16 @@ pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socke
.route("/api/v1/recipes", get(api_recipes).post(api_save_recipes))
// recipe entry api path route
.route("/api/v1/recipe/:recipe_id", get(api_recipe_entry))
// TODO(jwall): We should use route_layer to enforce the authorization
// requirements here.
// mealplan api path routes
.route("/api/v1/plan", get(api_plan).post(api_save_plan))
.route("/api/v1/plan/:date", get(api_plan_since))
// Inventory api path route
.route(
"/api/v1/inventory",
get(api_inventory).post(api_save_inventory),
)
// categories api path route
.route(
"/api/v1/categories",

View File

@ -0,0 +1 @@
select name, form, measure_type from filtered_ingredients where user_id = ?

View File

@ -0,0 +1 @@
select name, form, measure_type, amt from modified_amts where user_id = ?;

View File

@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use async_std::sync::Arc;
use std::collections::BTreeSet;
use std::str::FromStr;
use std::{collections::BTreeMap, path::Path};
@ -28,7 +29,7 @@ use axum::{
};
use chrono::NaiveDate;
use ciborium;
use recipes::RecipeEntry;
use recipes::{IngredientKey, RecipeEntry};
use secrecy::{ExposeSecret, Secret};
use serde::{Deserialize, Serialize};
use sqlx::{
@ -119,6 +120,18 @@ pub trait APIStore {
recipe_counts: &Vec<(String, i32)>,
date: NaiveDate,
) -> Result<()>;
async fn fetch_inventory_data<S: AsRef<str> + Send>(
&self,
user_id: S,
) -> Result<(BTreeSet<IngredientKey>, BTreeMap<IngredientKey, String>)>;
async fn save_inventory_data<S: AsRef<str> + Send>(
&self,
user_id: S,
filtered_ingredients: BTreeSet<IngredientKey>,
modified_amts: BTreeMap<IngredientKey, String>,
) -> Result<()>;
}
#[async_trait]
@ -479,4 +492,106 @@ impl APIStore for SqliteStore {
}
Ok(Some(result))
}
async fn fetch_inventory_data<S: AsRef<str> + Send>(
&self,
user_id: S,
) -> Result<(BTreeSet<IngredientKey>, BTreeMap<IngredientKey, String>)> {
let user_id = user_id.as_ref();
struct FilteredIngredientRow {
name: String,
form: String,
measure_type: String,
}
let filtered_ingredient_rows: Vec<FilteredIngredientRow> = sqlx::query_file_as!(
FilteredIngredientRow,
"src/web/storage/fetch_inventory_filtered_ingredients.sql",
user_id
)
.fetch_all(self.pool.as_ref())
.await?;
let mut filtered_ingredients = BTreeSet::new();
for row in filtered_ingredient_rows {
filtered_ingredients.insert(IngredientKey::new(
row.name,
if row.form.is_empty() {
None
} else {
Some(row.form)
},
row.measure_type,
));
}
struct ModifiedAmtRow {
name: String,
form: String,
measure_type: String,
amt: String,
}
let modified_amt_rows = sqlx::query_file_as!(
ModifiedAmtRow,
"src/web/storage/fetch_inventory_modified_amts.sql",
user_id,
)
.fetch_all(self.pool.as_ref())
.await?;
let mut modified_amts = BTreeMap::new();
for row in modified_amt_rows {
modified_amts.insert(
IngredientKey::new(
row.name,
if row.form.is_empty() {
None
} else {
Some(row.form)
},
row.measure_type,
),
row.amt,
);
}
Ok((filtered_ingredients, modified_amts))
}
async fn save_inventory_data<S: AsRef<str> + Send>(
&self,
user_id: S,
filtered_ingredients: BTreeSet<IngredientKey>,
modified_amts: BTreeMap<IngredientKey, String>,
) -> Result<()> {
let user_id = user_id.as_ref();
let mut transaction = self.pool.as_ref().begin().await?;
for key in filtered_ingredients {
let name = key.name();
let form = key.form();
let measure_type = key.measure_type();
sqlx::query_file!(
"src/web/storage/save_inventory_filtered_ingredients.sql",
user_id,
name,
form,
measure_type,
)
.execute(&mut transaction)
.await?;
}
for (key, amt) in modified_amts {
let name = key.name();
let form = key.form();
let measure_type = key.measure_type();
let amt = &amt;
sqlx::query_file!(
"src/web/storage/save_inventory_modified_amts.sql",
user_id,
name,
form,
measure_type,
amt,
)
.execute(&mut transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
}

View File

@ -0,0 +1,2 @@
insert into filtered_ingredients(user_id, name, form, measure_type)
values (?, ?, ?, ?)

View File

@ -0,0 +1,2 @@
insert into modified_amts(user_id, name, form, measure_type, amt)
values (?, ?, ?, ?, ?)

View File

@ -202,9 +202,27 @@ impl Step {
/// Unique identifier for an Ingredient. Ingredients are identified by name, form,
/// and measurement type. (Volume, Count, Weight)
#[derive(PartialEq, PartialOrd, Eq, Ord, Clone, Hash, Debug)]
#[derive(PartialEq, PartialOrd, Eq, Ord, Clone, Hash, Debug, Deserialize, Serialize)]
pub struct IngredientKey(String, Option<String>, String);
impl IngredientKey {
pub fn new(name: String, form: Option<String>, measure_type: String) -> Self {
Self(name, form, measure_type)
}
pub fn name(&self) -> &String {
&self.0
}
pub fn form(&self) -> String {
self.1.clone().unwrap_or_else(|| String::new())
}
pub fn measure_type(&self) -> &String {
&self.2
}
}
/// Ingredient in a recipe. The `name` and `form` fields with the measurement type
/// uniquely identify an ingredient.
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord)]

View File

@ -11,14 +11,14 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use reqwasm;
use serde_json::{from_str, to_string};
use sycamore::prelude::*;
use tracing::{debug, error, info, instrument, warn};
use recipes::{parse, Recipe, RecipeEntry};
use recipes::{parse, IngredientKey, Recipe, RecipeEntry};
use wasm_bindgen::JsValue;
use crate::{app_state, js_lib};
@ -92,6 +92,16 @@ pub async fn init_page_state(store: &HttpStore, state: &app_state::State) -> Res
error!("{:?}", e);
}
}
info!("Synchronizing inventory data");
match store.get_inventory_data().await {
Ok((filtered_ingredients, modified_amts)) => {
state.reset_modified_amts(modified_amts);
state.filtered_ingredients.set(filtered_ingredients);
}
Err(e) => {
error!("{:?}", e);
}
}
Ok(())
}
@ -371,4 +381,41 @@ impl HttpStore {
Ok(resp.json().await?)
}
}
pub async fn get_inventory_data(
&self,
) -> Result<(BTreeSet<IngredientKey>, BTreeMap<IngredientKey, String>), Error> {
let mut path = self.root.clone();
path.push_str("/inventory");
let resp = reqwasm::http::Request::get(&path).send().await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back");
let inventory = resp.json().await.map_err(|e| format!("{}", e))?;
Ok(inventory)
}
}
pub async fn save_inventory_data(
&self,
filtered_ingredients: BTreeSet<IngredientKey>,
modified_amts: BTreeMap<IngredientKey, String>,
) -> Result<(), Error> {
let mut path = self.root.clone();
let serialized_inventory = to_string(&(filtered_ingredients, modified_amts))
.expect("Unable to encode plan as json");
path.push_str("/inventory");
let resp = reqwasm::http::Request::post(&path)
.body(&serialized_inventory)
.header("content-type", "application/json")
.send()
.await?;
if resp.status() != 200 {
Err(format!("Status: {}", resp.status()).into())
} else {
debug!("We got a valid response back!");
Ok(())
}
}
}

View File

@ -16,7 +16,7 @@ use std::collections::{BTreeMap, BTreeSet};
use sycamore::prelude::*;
use tracing::{debug, instrument, warn};
use recipes::{Ingredient, IngredientAccumulator, Recipe};
use recipes::{Ingredient, IngredientAccumulator, IngredientKey, Recipe};
pub struct State {
pub recipe_counts: RcSignal<BTreeMap<String, RcSignal<usize>>>,
@ -24,6 +24,8 @@ pub struct State {
pub staples: RcSignal<Option<Recipe>>,
pub recipes: RcSignal<BTreeMap<String, Recipe>>,
pub category_map: RcSignal<BTreeMap<String, String>>,
pub filtered_ingredients: RcSignal<BTreeSet<IngredientKey>>,
pub modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>,
}
impl State {
@ -34,6 +36,8 @@ impl State {
staples: create_rc_signal(None),
recipes: create_rc_signal(BTreeMap::new()),
category_map: create_rc_signal(BTreeMap::new()),
filtered_ingredients: create_rc_signal(BTreeSet::new()),
modified_amts: create_rc_signal(BTreeMap::new()),
}
}
@ -103,7 +107,7 @@ impl State {
}
pub fn reset_recipe_counts(&self) {
for (key, count) in self.recipe_counts.get_untracked().iter() {
for (_, count) in self.recipe_counts.get_untracked().iter() {
count.set(0);
}
}
@ -119,4 +123,23 @@ impl State {
self.recipe_counts.set(counts);
self.recipe_counts.get_untracked().get(key).unwrap().clone()
}
pub fn get_current_modified_amts(&self) -> BTreeMap<IngredientKey, String> {
let mut modified_amts = BTreeMap::new();
for (key, amt) in self.modified_amts.get_untracked().iter() {
modified_amts.insert(key.clone(), amt.get_untracked().as_ref().clone());
}
modified_amts
}
pub fn reset_modified_amts(&self, modified_amts: BTreeMap<IngredientKey, String>) {
let mut modified_amts_copy = self.modified_amts.get().as_ref().clone();
for (key, amt) in modified_amts {
modified_amts_copy
.entry(key)
.and_modify(|amt_signal| amt_signal.set(amt.clone()))
.or_insert_with(|| create_rc_signal(amt));
}
self.modified_amts.set(modified_amts_copy);
}
}

View File

@ -14,13 +14,13 @@
use std::collections::{BTreeMap, BTreeSet};
use recipes::{Ingredient, IngredientKey};
use sycamore::prelude::*;
use sycamore::{futures::spawn_local_scoped, prelude::*};
use tracing::{debug, instrument};
fn make_ingredients_rows<'ctx, G: Html>(
cx: Scope<'ctx>,
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
modified_amts: &'ctx Signal<BTreeMap<IngredientKey, RcSignal<String>>>,
modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>,
filtered_keys: RcSignal<BTreeSet<IngredientKey>>,
) -> View<G> {
view!(
@ -111,7 +111,7 @@ fn make_extras_rows<'ctx, G: Html>(
fn make_shopping_table<'ctx, G: Html>(
cx: Scope<'ctx>,
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
modified_amts: &'ctx Signal<BTreeMap<IngredientKey, RcSignal<String>>>,
modified_amts: RcSignal<BTreeMap<IngredientKey, RcSignal<String>>>,
extras: RcSignal<Vec<(usize, (RcSignal<String>, RcSignal<String>))>>,
filtered_keys: RcSignal<BTreeSet<IngredientKey>>,
) -> View<G> {
@ -137,10 +137,12 @@ fn make_shopping_table<'ctx, G: Html>(
#[instrument]
#[component]
pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
let filtered_keys: RcSignal<BTreeSet<IngredientKey>> = create_rc_signal(BTreeSet::new());
let state = crate::app_state::State::get_from_context(cx);
// FIXME(jwall): We need to init this state for the page at some point.
let filtered_keys: RcSignal<BTreeSet<IngredientKey>> = state.filtered_ingredients.clone();
let ingredients_map = create_rc_signal(BTreeMap::new());
let modified_amts = create_signal(cx, BTreeMap::new());
let show_staples = create_signal(cx, true);
let save_click = create_signal(cx, ());
create_effect(cx, {
let state = crate::app_state::State::get_from_context(cx);
let ingredients_map = ingredients_map.clone();
@ -174,7 +176,7 @@ pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
table_view.set(make_shopping_table(
cx,
ingredients,
modified_amts.clone(),
state.modified_amts.clone(),
state.extras.clone(),
filtered_keys.clone(),
));
@ -183,6 +185,22 @@ pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
}
}
});
create_effect(cx, move || {
save_click.track();
spawn_local_scoped(cx, {
let state = crate::app_state::State::get_from_context(cx);
let store = crate::api::HttpStore::get_from_context(cx);
async move {
store
.save_inventory_data(
state.filtered_ingredients.get_untracked().as_ref().clone(),
state.get_current_modified_amts(),
)
.await
.expect("Unable to save inventory data");
}
})
});
let state = crate::app_state::State::get_from_context(cx);
view! {cx,
h1 { "Shopping List " }
@ -201,9 +219,12 @@ pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
// clear the filter_signal
filtered_keys.set(BTreeSet::new());
modified_amts.set(BTreeMap::new());
state.modified_amts.set(BTreeMap::new());
state.extras.set(Vec::new());
}
})
input(type="button", value="Save", class="no-print", on:click=|_| {
save_click.trigger_subscribers();
})
}
}