From 123a95277d0f2c54273fc9c2c704b4404c2ac74c Mon Sep 17 00:00:00 2001 From: Wes Holland Date: Fri, 18 Apr 2025 16:21:43 -0500 Subject: [PATCH] Add base of edit item form --- src/app/item/delete.rs | 14 +++ src/app/item/edit.rs | 184 +++++++++++++++++++++++++++++ src/app/item/item.rs | 4 +- src/app/item/mod.rs | 2 + src/app/item/stats.rs | 4 +- src/app/routes.rs | 39 +++--- src/db/inventory_item.rs | 108 ++++++++++++++++- src/util/form/edit_field_value.rs | 132 +++++++++++++++++++++ src/util/form/mod.rs | 3 +- templates/item/item-edit-form.html | 148 +++++++++++++++++++++++ templates/item/item.html | 16 ++- 11 files changed, 631 insertions(+), 23 deletions(-) create mode 100644 src/app/item/delete.rs create mode 100644 src/app/item/edit.rs create mode 100644 src/util/form/edit_field_value.rs create mode 100644 templates/item/item-edit-form.html diff --git a/src/app/item/delete.rs b/src/app/item/delete.rs new file mode 100644 index 0000000..f61a3e8 --- /dev/null +++ b/src/app/item/delete.rs @@ -0,0 +1,14 @@ +use axum::debug_handler; +use axum::extract::{Path, State}; +use sqlx::SqlitePool; +use askama_axum::{IntoResponse, Response}; +use crate::db::inventory_item::delete_inventory_item; +use crate::error::AppError; + +#[debug_handler] +pub async fn delete_item(State(db): State, Path(id): Path) -> Result { + delete_inventory_item(&db, id).await?; + + let response = format!("Item {} Deleted", id); + Ok(response.into_response()) +} diff --git a/src/app/item/edit.rs b/src/app/item/edit.rs new file mode 100644 index 0000000..8afae5a --- /dev/null +++ b/src/app/item/edit.rs @@ -0,0 +1,184 @@ +use crate::error::AppError; +use crate::session::SessionUser; +use askama::Template; +use askama_axum::{IntoResponse, Response}; +use axum::extract::{Path, State}; +use axum::{async_trait, debug_handler}; +use axum_htmx::{HxEvent, HxResponseTrigger}; +use serde::Deserialize; +use crate::app::routes::AppState; +use crate::db::display_unit::DbDisplayUnit; +use crate::db; +use crate::db::inventory_item::{DbInventoryItem, DbInventoryItemEditableFields}; +use crate::util::form::validate_form::{ValidateForm, ValidateFormError}; +use crate::util::form::helpers::deserialize_form_checkbox; +use crate::util::form::base_response::BaseResponse; +use crate::util::form::edit_field_value::{EditBoolField, EditFloatField, EditStringField}; +use crate::util::form::extractors::{FormBase, FormWithPathVars, ValidForm}; +use crate::util::form::field_error::FieldError; + +#[derive(Template, Debug, Default)] +#[template(path = "item/item-edit-form.html")] +pub struct EditItemFormTemplate { + pub item_id: i64, + pub display_units: Vec, + pub name: EditStringField, + pub display_unit_value: EditStringField, + pub reorder_point: EditStringField, + pub pims_id: EditStringField, + pub vetcove_id: EditStringField, + pub allow_fractional_units: EditBoolField, +} + +impl EditItemFormTemplate { + pub fn base(item: DbInventoryItemEditableFields, display_units: Vec) -> Self { + let name = EditStringField::new(item.name); + let pims_id = EditStringField::from(item.pims_id); + let vetcove_id = EditStringField::from(item.vetcove_id); + let allow_fractional_units = EditBoolField::new(item.allow_fractional_units); + let display_unit_value = EditStringField::new(item.display_unit_abbreviation); + + let precision = if item.allow_fractional_units { 2 } else { 0 }; + let reorder_point= format!("{:.*}", precision, item.reorder_point); + let reorder_point = EditStringField::new(reorder_point); + + Self { + item_id: item.id, + name, + reorder_point, + pims_id, + vetcove_id, + allow_fractional_units, + display_unit_value, + display_units, + } + + } +} + + +#[derive(Deserialize, Debug)] +pub struct EditItemFormData { + name: String, + display_unit: String, + reorder_point: f64, + pims_id: Option, + vetcove_id: Option, + #[serde(default, deserialize_with = "deserialize_form_checkbox")] + allow_fractional_units: bool, +} + +#[async_trait] +impl ValidateForm for FormWithPathVars { + type ValidationErrorResponse = EditItemFormTemplate; + + async fn validate(self, state: &AppState) -> Result> { + let item_id = self.path_data; + let display_units = db::display_unit::get_display_units(&state.db).await?; + let current_values = db::inventory_item::inventory_item_get_by_id_editable_fields(&state.db, item_id).await?; + + let name = EditStringField::new_with_base( + self.form_data.name.clone(), + current_values.name.clone(), + ) + .invalid_if(|v| v.is_empty(), FieldError::Required); + + let display_unit_value = EditStringField::new_with_base( + self.form_data.display_unit.clone(), + current_values.display_unit_abbreviation.clone(), + ) + .invalid_if(|v| v.is_empty(), FieldError::Required) + .invalid_if(|v| !display_units.iter().any( + |x| x.abbreviation.eq(&self.form_data.display_unit)), + FieldError::SelectOption) + .reset_if_error(); + + let reorder_point = EditFloatField::new_with_base( + self.form_data.reorder_point.clone(), + current_values.reorder_point.clone(), + ) + .invalid_if(|v| v.is_nan() || v.is_infinite() || v.is_sign_negative(), FieldError::PositiveNumber) + .invalid_if(|v| !(self.form_data.allow_fractional_units || v.fract() == 0.0), FieldError::WholeNumber) + .map(|v, b| (format!("{:.2}", v), format!("{:.2}", b))); + + let pims_id = EditStringField::new_with_base( + self.form_data.pims_id.clone().unwrap_or_default(), + current_values.pims_id.unwrap_or_default(), + ) + .invalid_if(|v| v.chars().any(char::is_whitespace), FieldError::ValidIdentifier); + + let vetcove_id = EditStringField::new_with_base( + self.form_data.vetcove_id.clone().unwrap_or_default(), + current_values.vetcove_id.unwrap_or_default(), + ) + .invalid_if(|v| !v.chars().all(|c| c.is_ascii_digit()), FieldError::ValidIdentifier); + + let allow_fractional_units = EditBoolField::new_with_base( + self.form_data.allow_fractional_units, + current_values.allow_fractional_units, + ); + + if name.is_error() || + display_unit_value.is_error() || + reorder_point.is_error() || + pims_id.is_error() || + vetcove_id.is_error() { + + return Err(ValidateFormError::ValidationError( + Self::ValidationErrorResponse { + item_id, + display_units, + name, + display_unit_value, + reorder_point, + pims_id, + vetcove_id, + allow_fractional_units, + })); + } + + + Ok(self) + } +} + +pub async fn edit_item_form_post( + State(state): State, + user: SessionUser, + Path(id): Path, + form: ValidForm>, +) -> Result { + + let form_data = &form.form_data; + + db::inventory_item::update_inventory_item(&state.db, id, &form_data.name, form_data.reorder_point, + form_data.allow_fractional_units, &form_data.display_unit, + &form_data.pims_id, &form_data.vetcove_id, true, + ).await?; + + let new_base = db::inventory_item::inventory_item_get_by_id_editable_fields(&state.db, id).await?; + let display_units = db::display_unit::get_display_units(&state.db).await?; + let fresh_form = EditItemFormTemplate::base(new_base, display_units); + + let events = vec![ + HxEvent::from("form-submit-success"), + HxEvent::from("item-updated"), + ]; + + Ok( ( + HxResponseTrigger::normal(events), + fresh_form.into_response() + ).into_response() ) + +} + +#[debug_handler] +pub async fn edit_item_form_get( + Path(id): Path, + State(state): State, +) -> Result { + let item_vars = db::inventory_item::inventory_item_get_by_id_editable_fields(&state.db, id).await?; + let display_units = db::display_unit::get_display_units(&state.db).await?; + let template = EditItemFormTemplate::base(item_vars, display_units); + Ok(template.into_response()) +} diff --git a/src/app/item/item.rs b/src/app/item/item.rs index 381ccf8..6c298fd 100644 --- a/src/app/item/item.rs +++ b/src/app/item/item.rs @@ -1,4 +1,4 @@ -use crate::db::inventory_item::{inventory_item_get_by_id_with_unit, DbInventoryItemWithCount}; +use crate::db::inventory_item::{inventory_item_get_by_id_with_unit_and_count, DbInventoryItemWithCount}; use crate::error::AppError; use askama::Template; use askama_axum::{IntoResponse, Response}; @@ -15,7 +15,7 @@ struct ItemTemplate { #[debug_handler] pub async fn item(State(db): State, Path(id): Path) -> Result { - let item: ItemDisplayItem = inventory_item_get_by_id_with_unit(&db, id).await?.into(); + let item: ItemDisplayItem = inventory_item_get_by_id_with_unit_and_count(&db, id).await?.into(); Ok(ItemTemplate { item_id: id, item }.into_response()) } diff --git a/src/app/item/mod.rs b/src/app/item/mod.rs index fa86c5b..93681e0 100644 --- a/src/app/item/mod.rs +++ b/src/app/item/mod.rs @@ -3,3 +3,5 @@ pub mod item; pub mod stats; pub mod count; pub mod create; +pub mod delete; +pub mod edit; diff --git a/src/app/item/stats.rs b/src/app/item/stats.rs index 82ba29e..8112d6c 100644 --- a/src/app/item/stats.rs +++ b/src/app/item/stats.rs @@ -6,7 +6,7 @@ use anyhow::anyhow; use axum::debug_handler; use crate::app::item::item::ItemDisplayItem; use crate::db::adjustment::get_item_adjustment_valuation_weighted_mean; -use crate::db::inventory_item::inventory_item_get_by_id_with_unit; +use crate::db::inventory_item::inventory_item_get_by_id_with_unit_and_count; use crate::error::AppError; use crate::util::currency::int_cents_to_dollars_string; @@ -22,7 +22,7 @@ struct ItemStatsTemplate { pub async fn item_stats(State(db): State, Path(id): Path) -> Result { //TODO This is pretty chatty with the database. Might could cut down the // number of queries - let item: ItemDisplayItem = inventory_item_get_by_id_with_unit(&db, id).await?.into(); + let item: ItemDisplayItem = inventory_item_get_by_id_with_unit_and_count(&db, id).await?.into(); let value = get_item_adjustment_valuation_weighted_mean(&db, id) .await? diff --git a/src/app/routes.rs b/src/app/routes.rs index f27f16b..3badb27 100644 --- a/src/app/routes.rs +++ b/src/app/routes.rs @@ -2,7 +2,7 @@ use crate::app::{catalog, history, home, item, overview, reports, upload}; use crate::session::SessionUser; use axum::extract::FromRef; use axum::middleware::from_extractor; -use axum::routing::{get, post}; +use axum::routing::{get, post, delete}; use axum::Router; use axum_htmx::AutoVaryLayer; use oauth2::basic::BasicClient; @@ -16,27 +16,34 @@ pub fn routes() -> Router { .route("/home/search", get(home::home)) .route("/catalog", get(catalog::catalog)) .route("/catalog/", get(catalog::catalog)) - .route("/item/:item_id", get(item::item::item)) - .route("/item/:item_id/", get(item::item::item)) + .route("/item/:item_id", + get(item::item::item). + delete(item::delete::delete_item) + ) + .route("/item/:item_id/", + get(item::item::item). + delete(item::delete::delete_item) + ) .route("/item/:item_id/count", get(item::count::item_count)) .route("/item/:item_id/stats", get(item::stats::item_stats)) + .route("/item/:item_id/edit", + get(item::edit::edit_item_form_get). + post(item::edit::edit_item_form_post) + ) .route("/item/create", - get(item::create::create_item_form_get) - .post(item::create::create_item_form_post) + get(item::create::create_item_form_get). + post(item::create::create_item_form_post) ) - .route( - "/item/:item_id/adjustments", - get(item::adjustment::table::get_adjustments_table), + .route("/item/:item_id/adjustments", + get(item::adjustment::table::get_adjustments_table), ) - .route( - "/item/:item_id/adjustment/negative", - get(item::adjustment::negative::negative_adjustment_form_get) - .post(item::adjustment::negative::negative_adjustment_form_post), + .route("/item/:item_id/adjustment/negative", + get(item::adjustment::negative::negative_adjustment_form_get). + post(item::adjustment::negative::negative_adjustment_form_post), ) - .route( - "/item/:item_id/adjustment/positive", - get(item::adjustment::positive::positive_adjustment_form_get) - .post(item::adjustment::positive::positive_adjustment_form_post), + .route("/item/:item_id/adjustment/positive", + get(item::adjustment::positive::positive_adjustment_form_get). + post(item::adjustment::positive::positive_adjustment_form_post), ) .route("/upload", get(upload::index::upload_index_handler)) .route("/upload/catalog", post(upload::catalog::catalog_import)) diff --git a/src/db/inventory_item.rs b/src/db/inventory_item.rs index c48d389..a88078d 100644 --- a/src/db/inventory_item.rs +++ b/src/db/inventory_item.rs @@ -1,6 +1,7 @@ use serde::Serialize; use anyhow::Result; use sqlx::SqlitePool; +use tracing::info; #[derive(Debug, Serialize)] #[derive(sqlx::FromRow)] @@ -119,6 +120,39 @@ pub async fn add_inventory_item(db: &SqlitePool, name: &str, reorder_point: f64, Ok(new_id) } +pub async fn delete_inventory_item(db: &SqlitePool, id: i64) -> Result<()> { + let mut tx = db.begin().await?; + + let adjustments = sqlx::query!( + r#" + DELETE FROM Adjustment WHERE item = ? + "#, + id + ).execute(&mut *tx) + .await? + .rows_affected(); + + let items = sqlx::query!( + r#" + DELETE FROM InventoryItem WHERE id = ? + "#, + id + ).execute(&mut *tx) + .await? + .rows_affected(); + + tx.commit().await?; + + if items == 0 { + info!("Delete requested for {}, but no such item exists", id); + } + else { + info!("Item {} deleted. {} adjustments", id, adjustments); + } + + Ok(()) +} + #[derive(Debug, Serialize)] #[derive(sqlx::FromRow)] pub struct DbInventoryItemWithCount { @@ -133,7 +167,7 @@ pub struct DbInventoryItemWithCount { pub active: bool, } -pub async fn inventory_item_get_by_id_with_unit(db: &SqlitePool, id: i64) -> Result { +pub async fn inventory_item_get_by_id_with_unit_and_count(db: &SqlitePool, id: i64) -> Result { sqlx::query_as!( DbInventoryItemWithCount, r#" @@ -174,3 +208,75 @@ pub async fn does_inventory_item_allow_fractional_units(db: &SqlitePool, id: i64 Ok(res.allow_fractional_units) } + +#[derive(Debug, Serialize)] +#[derive(sqlx::FromRow)] +pub struct DbInventoryItemEditableFields { + pub id: i64, + pub name: String, + pub reorder_point: f64, + pub allow_fractional_units: bool, + pub display_unit: i64, + pub display_unit_str: String, + pub display_unit_abbreviation: String, + pub active: bool, + pub pims_id: Option, + pub vetcove_id: Option, +} + +pub async fn inventory_item_get_by_id_editable_fields(db: &SqlitePool, id: i64) -> Result { + sqlx::query_as!( + DbInventoryItemEditableFields, + r#" + SELECT + item.id as id, + item.name as name, + item.reorder_point as reorder_point, + item.allow_fractional_units as allow_fractional_units, + item.display_unit as display_unit, + display_unit.unit as display_unit_str, + display_unit.abbreviation as display_unit_abbreviation, + item.active as active, + item.pims_id as pims_id, + item.vetcove_id as vetcove_id + FROM + InventoryItem as item + JOIN DisplayUnit as display_unit ON item.display_unit = display_unit.id + WHERE item.id = ? + "#, + id + ) + .fetch_one(db).await + .map_err(From::from) +} + +pub async fn update_inventory_item(db: &SqlitePool, id: i64, name: &str, reorder_point: f64, + allow_fractional_units: bool, display_unit_abbreviation: &str, + pims_id: &Option, vetcove_id: &Option, active: bool +) -> Result<()> { + let affected = sqlx::query!( + r#" + UPDATE InventoryItem SET + name = ?, + reorder_point = ?, + allow_fractional_units = ?, + display_unit = (SELECT id from DisplayUnit WHERE abbreviation = ? ), + active = ?, + pims_id = ?, + vetcove_id = ? + WHERE id = ? + "#, + name, + reorder_point, + allow_fractional_units, + display_unit_abbreviation, + active, + pims_id, + vetcove_id, + id + ).execute(db).await?.rows_affected(); + + assert_eq!(affected, 1); + + Ok(()) +} diff --git a/src/util/form/edit_field_value.rs b/src/util/form/edit_field_value.rs new file mode 100644 index 0000000..bb4a5d0 --- /dev/null +++ b/src/util/form/edit_field_value.rs @@ -0,0 +1,132 @@ +use std::fmt::{Debug, Display}; +use crate::util::form::field_error::FieldError; + +/** +A sum type for use with forms and form validation. Similar to a basic field_value, +but contains a base value that can be compared to and reverted to. The base value is +assumed to be "good" in terms of validation, meaning the Error refers to the current +value rather than the base value. +*/ + +pub struct EditFieldValue { + pub value: Value, + pub base: Value, + pub error: Option, +} + +pub type EditStringField = EditFieldValue; +pub type EditBoolField = EditFieldValue; +pub type EditFloatField = EditFieldValue; + +impl EditFieldValue +where Value: Default + Clone + PartialEq, Error: ToString +{ + pub fn new(value: Value) -> Self { + let base = value.clone(); + Self { value, base, error: None } + } + + pub fn new_with_base(value: Value, base: Value) -> Self { + Self { value, base, error: None } + } + + pub fn set_error(&mut self, error: Error) { + self.error = Some(error); + } + + pub fn is_error(&self) -> bool { + self.error.is_some() + } + + pub fn invalid_if(mut self, is_invalid: I, err: Error ) -> Self + where I: FnOnce(&Value) -> bool { + if is_invalid(&self.value) { + self.set_error(err); + } + self + } + + pub fn invalid_if_then(mut self, is_invalid: I, err_if: E ) -> Self + where I: FnOnce(&Value) -> bool, E: FnOnce() -> Error { + if is_invalid(&self.value) { + self.set_error(err_if()); + } + self + } + + pub fn with_base(mut self, base: Value) -> Self { + self.base = base; + self + } + + pub fn is_changed(&self) -> bool { + self.value != self.base + } + + pub fn reset(mut self) -> Self { + self.value = self.base.clone(); + self + } + + pub fn reset_if_error(mut self) -> Self { + if self.is_error() { self.reset() } else { self } + } + + pub fn map(self, f: F) -> EditFieldValue + where F: FnOnce(Value, Value) -> (V, V) { + let (value, base) = f(self.value, self.base); + EditFieldValue:: { + value, + base, + error: self.error, + } + } + + pub fn with_error(mut self, error: Error) -> Self { + self.set_error(error); + self + } + + pub fn error_string(&self) -> String { + match self.error { + Some(ref error) => error.to_string(), + None => "Unknown".to_string(), + } + } +} + +impl Default for EditFieldValue +where Value: Default + Clone + PartialEq, Error: ToString +{ + fn default() -> Self { + Self::new(Value::default()) + } +} + +impl Debug for EditFieldValue +where Value: Debug, + Error: Debug { + fn fmt(&self, fmt: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> { + fmt.debug_struct("EditFieldValue") + .field("value", &self.value) + .field("error", &self.error) + .finish() + } +} + +impl From> for EditFieldValue +where Value: Default + Clone + PartialEq, Error: ToString { + fn from(value: Option) -> Self { + Self::new(value.unwrap_or_default()) + } +} + +impl From> for EditFieldValue +where Value: Default + Clone + PartialEq, Error: ToString { + fn from(value: Result) -> Self { + match value { + Ok(value) => Self::new(value), + Err(error) => Self::default().with_error(error), + } + } +} diff --git a/src/util/form/mod.rs b/src/util/form/mod.rs index a03f4cf..c96d836 100644 --- a/src/util/form/mod.rs +++ b/src/util/form/mod.rs @@ -3,4 +3,5 @@ pub mod field_value; pub mod validate_form; pub mod extractors; pub mod helpers; -pub mod base_response; \ No newline at end of file +pub mod base_response; +pub mod edit_field_value; \ No newline at end of file diff --git a/templates/item/item-edit-form.html b/templates/item/item-edit-form.html new file mode 100644 index 0000000..ecd5960 --- /dev/null +++ b/templates/item/item-edit-form.html @@ -0,0 +1,148 @@ +
+
+
+ + + {% if name.is_error() -%} + + {{ name.error_string() }} + + {% endif -%} +
+ +
+ + + {% if reorder_point.is_error() -%} + + {{ reorder_point.error_string() }} + + {% endif -%} +
+ +
+ + + {% if display_unit_value.is_error() -%} + + {{ display_unit_value.error_string() }} + + {% endif -%} +
+ +
+ + +
+ + +
+ + + {% if pims_id.is_error() -%} + + {{ pims_id.error_string() }} + + {% endif -%} +
+ +
+ + + {% if vetcove_id.is_error() -%} + + {{ vetcove_id.error_string() }} + + {% endif -%} +
+
+ +
+
+ + +
+
+
diff --git a/templates/item/item.html b/templates/item/item.html index d195b07..c31183c 100644 --- a/templates/item/item.html +++ b/templates/item/item.html @@ -37,6 +37,12 @@ > Plus + {% endif %} @@ -48,14 +54,15 @@ hx-swap="outerHTML" > + - {% endblock %} {% block sidebar_title %}

Negative Adjustment

Positive Adjustment

+

Edit

{% endblock %} {% block sidebar_content %} @@ -74,5 +81,12 @@ hx-swap="outerHTML" > +
+
+
{% endblock %}