From a3939e972dcd00687928617d7591755751a892ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 29 Aug 2023 21:33:59 +0200 Subject: [PATCH] migration, adding categories, small fixes --- rust/.gitignore | 2 + rust/migrations/20230519222555_initial.sql | 57 ++++++++++ rust/src/components/inventory.rs | 51 +++++++++ rust/src/components/trip.rs | 110 +++++++++++--------- rust/src/main.rs | 113 ++++++++++++++++---- rust/src/models.rs | 115 +++++++++++++-------- 6 files changed, 333 insertions(+), 115 deletions(-) create mode 100644 rust/migrations/20230519222555_initial.sql diff --git a/rust/.gitignore b/rust/.gitignore index 17c5392..4371c4e 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -1,2 +1,4 @@ /target/ *.sqlite +*.sqlite-wal +*.sqlite-shm diff --git a/rust/migrations/20230519222555_initial.sql b/rust/migrations/20230519222555_initial.sql new file mode 100644 index 0000000..8a5f88c --- /dev/null +++ b/rust/migrations/20230519222555_initial.sql @@ -0,0 +1,57 @@ +CREATE TABLE "inventory_items" ( + id TEXT, + name TEXT, + description TEXT, + weight INT, +category_id TEXT, +FOREIGN KEY (category_id) REFERENCES inventory_items_categories(id)); +CREATE UNIQUE INDEX ux_unique ON inventory_items(name, category_id); + +CREATE TABLE "inventory_items_categories" ( + id VARCHAR(36) NOT NULL, + name TEXT NOT NULL, + description TEXT, + PRIMARY KEY (id), + UNIQUE (name) +); + +CREATE TABLE "trips" ( + id VARCHAR(36) NOT NULL, + name TEXT NOT NULL, + date_start DATE NOT NULL, + date_end DATE NOT NULL, + location TEXT, + state VARCHAR(8) NOT NULL DEFAULT "Planning", + comment TEXT, + temp_min INTEGER, + temp_max INTEGER, + PRIMARY KEY (id), + UNIQUE (name) +); + + +CREATE TABLE "trips_types" ( + id VARCHAR(36) NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (id), + UNIQUE (name) +); + +CREATE TABLE "trips_to_trips_types" ( + trip_id VARCHAR(36) NOT NULL, + trip_type_id VARCHAR(36) NOT NULL, + PRIMARY KEY (trip_id, trip_type_id), + FOREIGN KEY(trip_id) REFERENCES "trips" (id), + FOREIGN KEY(trip_type_id) REFERENCES "trips_types" (id) +); + + +CREATE TABLE trips_items ( + item_id VARCHAR(36) NOT NULL, + trip_id VARCHAR(36) NOT NULL, + pick BOOLEAN NOT NULL, + pack BOOLEAN NOT NULL, + PRIMARY KEY (item_id, trip_id), + FOREIGN KEY(item_id) REFERENCES "inventory_items" (id), + FOREIGN KEY(trip_id) REFERENCES "trips" (id) +); diff --git a/rust/src/components/inventory.rs b/rust/src/components/inventory.rs index bba2f1e..6816f08 100644 --- a/rust/src/components/inventory.rs +++ b/rust/src/components/inventory.rs @@ -13,6 +13,7 @@ impl Inventory { div ."p-8" ."grid" ."grid-cols-4" ."gap-3" { div ."col-span-2" { (InventoryCategoryList::build(&state, &categories)) + (InventoryNewCategoryForm::build()) } div ."col-span-2" { h1 ."text-2xl" ."mb-5" ."text-center" { "Items" } @@ -409,3 +410,53 @@ impl InventoryNewItemForm { ) } } + +pub struct InventoryNewCategoryForm; + +impl InventoryNewCategoryForm { + pub fn build() -> Markup { + html!( + form + name="new-category" + id="new-category" + action="/inventory/category/" + target="_self" + method="post" + ."mt-8" ."p-5" ."border-2" ."border-gray-200" { + div ."mb-5" ."flex" ."flex-row" ."items-center" { + span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {} + p ."inline" ."text-xl" { "Add new category" } + } + div ."w-11/12" ."mx-auto" { + div ."pb-8" { + div ."flex" ."flex-row" ."justify-center" ."items-start"{ + label for="name" .font-bold ."w-1/2" ."p-2" ."text-center" { "Name" } + span ."w-1/2" { + input type="text" id="new-category-name" name="new-category-name" + ."block" + ."w-full" + ."p-2" + ."bg-gray-50" + ."border-2" + ."rounded" + ."focus:outline-none" + ."focus:bg-white" + ."focus:border-purple-500" + { + } + } + } + } + input type="submit" value="Add" + ."py-2" + ."border-2" + ."rounded" + ."border-gray-300" + ."mx-auto" + ."w-full" { + } + } + } + ) + } +} diff --git a/rust/src/components/trip.rs b/rust/src/components/trip.rs index 8bf8586..59fb922 100644 --- a/rust/src/components/trip.rs +++ b/rust/src/components/trip.rs @@ -411,61 +411,73 @@ impl TripInfo { ."flex-wrap" ."gap-2" ."justify-between" + // as we have a gap between the elements, we have + // to completely skip an element when there are no + // active or inactive items, otherwise we get the gap + // between the empty (invisible) item, throwing off + // the margins { @let types = trip.types(); - div - ."flex" - ."flex-row" - ."flex-wrap" - ."gap-2" - ."justify-start" - { - @for triptype in types.iter().filter(|t| t.active) { - a href=(format!("type/{}/remove", triptype.id)) { - li - ."border" - ."rounded-2xl" - ."py-0.5" - ."px-2" - ."bg-green-100" - ."cursor-pointer" - ."flex" - ."flex-column" - ."items-center" - ."hover:bg-red-200" - ."gap-1" - { - span { (triptype.name) } - span ."mdi" ."mdi-delete" ."text-sm" {} + @let active_triptypes = types.iter().filter(|t| t.active).collect::>(); + @let inactive_triptypes = types.iter().filter(|t| !t.active).collect::>(); + + @if !active_triptypes.is_empty() { + div + ."flex" + ."flex-row" + ."flex-wrap" + ."gap-2" + ."justify-start" + { + @for triptype in active_triptypes { + a href=(format!("type/{}/remove", triptype.id)) { + li + ."border" + ."rounded-2xl" + ."py-0.5" + ."px-2" + ."bg-green-100" + ."cursor-pointer" + ."flex" + ."flex-column" + ."items-center" + ."hover:bg-red-200" + ."gap-1" + { + span { (triptype.name) } + span ."mdi" ."mdi-delete" ."text-sm" {} + } } } } } - div - ."flex" - ."flex-row" - ."flex-wrap" - ."gap-2" - ."justify-start" - { - @for triptype in types.iter().filter(|t| !t.active) { - a href=(format!("type/{}/add", triptype.id)) { - li - ."border" - ."rounded-2xl" - ."py-0.5" - ."px-2" - ."bg-gray-100" - ."cursor-pointer" - ."flex" - ."flex-column" - ."items-center" - ."hover:bg-green-200" - ."gap-1" - ."opacity-60" - { - span { (triptype.name) } - span ."mdi" ."mdi-plus" ."text-sm" {} + @if !inactive_triptypes.is_empty() { + div + ."flex" + ."flex-row" + ."flex-wrap" + ."gap-2" + ."justify-start" + { + @for triptype in inactive_triptypes { + a href=(format!("type/{}/add", triptype.id)) { + li + ."border" + ."rounded-2xl" + ."py-0.5" + ."px-2" + ."bg-gray-100" + ."cursor-pointer" + ."flex" + ."flex-column" + ."items-center" + ."hover:bg-green-200" + ."gap-1" + ."opacity-60" + { + span { (triptype.name) } + span ."mdi" ."mdi-plus" ."text-sm" {} + } } } } diff --git a/rust/src/main.rs b/rust/src/main.rs index 8ef3a4e..8b4433c 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -9,6 +9,8 @@ use axum::{ Form, Router, }; +use std::str::FromStr; + use serde_variant::to_variant_name; use sqlx::{ @@ -72,13 +74,16 @@ async fn main() -> Result<(), sqlx::Error> { let database_pool = SqlitePoolOptions::new() .max_connections(5) .connect_with( - SqliteConnectOptions::new() - .filename(std::env::var("SQLITE_DATABASE").expect("env SQLITE_DATABASE not found")) - .pragma("foreign_keys", "1"), + SqliteConnectOptions::from_str( + &std::env::var("DATABASE_URL").expect("env DATABASE_URL not found"), + )? + .pragma("foreign_keys", "1"), ) .await .unwrap(); + sqlx::migrate!().run(&database_pool).await?; + let state = AppState { database_pool, client_state: ClientState::new(), @@ -102,6 +107,7 @@ async fn main() -> Result<(), sqlx::Error> { .route("/trip/:id/items/:id/pack", get(trip_item_set_pack)) .route("/trip/:id/items/:id/unpack", get(trip_item_set_unpack)) .route("/inventory/", get(inventory_inactive)) + .route("/inventory/category/", post(inventory_category_create)) .route("/inventory/item/", post(inventory_item_create)) .route("/inventory/category/:id/", get(inventory_active)) .route("/inventory/item/:id/delete", get(inventory_item_delete)) @@ -142,8 +148,8 @@ impl Default for InventoryQuery { } async fn inventory_active( - Path(id): Path, State(mut state): State, + Path(id): Path, Query(inventory_query): Query, ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { state.client_state.edit_item = inventory_query.edit_item; @@ -164,7 +170,7 @@ async fn inventory( ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { state.client_state.active_category_id = active_id; - let mut categories = query("SELECT id,name,description FROM inventoryitemcategories") + let mut categories = query("SELECT id,name,description FROM inventory_items_categories") .fetch(&state.database_pool) .map_ok(std::convert::TryInto::try_into) .try_collect::>>() @@ -236,7 +242,7 @@ async fn inventory_item_create( Form(new_item): Form, ) -> Result { query( - "INSERT INTO inventoryitems + "INSERT INTO inventory_items (id, name, description, weight, category_id) VALUES (?, ?, ?, ?, ?)", @@ -301,7 +307,7 @@ async fn inventory_item_delete( Path(id): Path, ) -> Result { let results = query( - "DELETE FROM inventoryitems + "DELETE FROM inventory_items WHERE id = ?", ) .bind(id.to_string()) @@ -346,7 +352,7 @@ async fn inventory_item_delete( // //TODO bind this stuff!!!!!!! no sql injection pls // "SELECT // i.id, i.name, i.description, i.weight, i.category_id -// FROM inventoryitemcategories AS c +// FROM inventory_items_categories AS c // INNER JOIN inventoryitems AS i // ON i.category_id = c.id WHERE c.id = '{id}';", // id = id, @@ -476,7 +482,7 @@ async fn trip_create( ), })?; - Ok(Redirect::to(&format!("/trips/{id}/", id = id.to_string()))) + Ok(Redirect::to(&format!("/trip/{id}/", id = id.to_string()))) } async fn trips( @@ -519,8 +525,8 @@ struct TripQuery { } async fn trip( - Path(id): Path, State(mut state): State, + Path(id): Path, Query(trip_query): Query, ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> { state.client_state.trip_edit_attribute = trip_query.edit; @@ -541,7 +547,7 @@ async fn trip( })? .map_err(|e: Error| (StatusCode::INTERNAL_SERVER_ERROR, ErrorPage::build(&e.to_string())))?; - trip.load_triptypes(&state.database_pool) + trip.load_trips_types(&state.database_pool) .await .map_err(|e| { ( @@ -577,11 +583,11 @@ async fn trip( } async fn trip_type_remove( - Path((trip_id, type_id)): Path<(Uuid, Uuid)>, State(state): State, + Path((trip_id, type_id)): Path<(Uuid, Uuid)>, ) -> Result { let results = query( - "DELETE FROM trips_to_triptypes AS ttt + "DELETE FROM trips_to_trips_types AS ttt WHERE ttt.trip_id = ? AND ttt.trip_type_id = ? ", @@ -603,11 +609,11 @@ async fn trip_type_remove( } async fn trip_type_add( - Path((trip_id, type_id)): Path<(Uuid, Uuid)>, State(state): State, + Path((trip_id, type_id)): Path<(Uuid, Uuid)>, ) -> Result { query( - "INSERT INTO trips_to_triptypes + "INSERT INTO trips_to_trips_types (trip_id, trip_type_id) VALUES (?, ?)", ) .bind(trip_id.to_string()) @@ -672,8 +678,8 @@ struct CommentUpdate { } async fn trip_comment_set( - Path(trip_id): Path, State(state): State, + Path(trip_id): Path, Form(comment_update): Form, ) -> Result { let result = query( @@ -704,8 +710,8 @@ struct TripUpdate { } async fn trip_edit_attribute( - Path((trip_id, attribute)): Path<(Uuid, TripAttribute)>, State(state): State, + Path((trip_id, attribute)): Path<(Uuid, TripAttribute)>, Form(trip_update): Form, ) -> Result { let result = query(&format!( @@ -738,7 +744,7 @@ async fn trip_item_set_state( value: bool, ) -> Result<(), (StatusCode, Markup)> { let result = query(&format!( - "UPDATE tripitems + "UPDATE trips_items SET {key} = ? WHERE trip_id = ? AND item_id = ?", @@ -764,9 +770,9 @@ async fn trip_item_set_state( } async fn trip_item_set_pick( + State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, - State(state): State, ) -> Result { Ok(trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, true).await?).map( |_| -> Result { @@ -790,9 +796,9 @@ async fn trip_item_set_pick( } async fn trip_item_set_unpick( + State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, - State(state): State, ) -> Result { Ok(trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, false).await?).map( |_| -> Result { @@ -816,9 +822,9 @@ async fn trip_item_set_unpick( } async fn trip_item_set_pack( + State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, - State(state): State, ) -> Result { Ok(trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, true).await?).map( |_| -> Result { @@ -842,9 +848,9 @@ async fn trip_item_set_pack( } async fn trip_item_set_unpack( + State(state): State, Path((trip_id, item_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, - State(state): State, ) -> Result { Ok(trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, false).await?).map( |_| -> Result { @@ -866,3 +872,66 @@ async fn trip_item_set_unpack( }, )? } + +#[derive(Deserialize)] +struct NewCategory { + #[serde(rename = "new-category-name")] + name: String, +} + +async fn inventory_category_create( + State(state): State, + Form(new_category): Form, +) -> Result { + let id = Uuid::new_v4(); + query( + "INSERT INTO inventory_items_categories + (id, name) + VALUES + (?, ?)", + ) + .bind(id.to_string()) + .bind(&new_category.name) + .execute(&state.database_pool) + .map_err(|e| match e { + sqlx::Error::Database(ref error) => { + let sqlite_error = error.downcast_ref::(); + if let Some(code) = sqlite_error.code() { + match &*code { + "2067" => { + // SQLITE_CONSTRAINT_UNIQUE + ( + StatusCode::BAD_REQUEST, + ErrorPage::build(&format!( + "category with name \"{name}\" already exists", + name = new_category.name + )), + ) + } + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&format!( + "got error with unknown code: {}", + sqlite_error.to_string() + )), + ), + } + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&format!( + "got error without code: {}", + sqlite_error.to_string() + )), + ) + } + } + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&format!("got unknown error: {}", e.to_string())), + ), + }) + .await?; + + Ok(Redirect::to("/inventory/")) +} diff --git a/rust/src/models.rs b/rust/src/models.rs index ac2541f..abe9d02 100644 --- a/rust/src/models.rs +++ b/rust/src/models.rs @@ -229,18 +229,18 @@ impl<'a> Trip { pub fn types(&'a self) -> &Vec { self.types .as_ref() - .expect("you need to call load_triptypes()") + .expect("you need to call load_trips_types()") } pub fn categories(&'a self) -> &Vec { self.categories .as_ref() - .expect("you need to call load_triptypes()") + .expect("you need to call load_trips_types()") } } impl<'a> Trip { - pub async fn load_triptypes( + pub async fn load_trips_types( &'a mut self, pool: &sqlx::Pool, ) -> Result<(), Error> { @@ -250,13 +250,13 @@ impl<'a> Trip { type.id as id, type.name as name, CASE WHEN inner.id IS NOT NULL THEN true ELSE false END AS active - FROM triptypes AS type + FROM trips_types AS type LEFT JOIN ( SELECT type.id as id, type.name as name FROM trips as trip - INNER JOIN trips_to_triptypes as ttt + INNER JOIN trips_to_trips_types as ttt ON ttt.trip_id = trip.id - INNER JOIN triptypes AS type + INNER JOIN trips_types AS type ON type.id == ttt.trip_type_id WHERE trip.id = ? ) AS inner @@ -287,19 +287,36 @@ impl<'a> Trip { SELECT category.id as category_id, category.name as category_name, - category.description as category_description, - item.id as item_id, - item.name as item_name, - item.description as item_description, - item.weight as item_weight, - trip.pick as item_is_picked, - trip.pack as item_is_packed - FROM tripitems as trip - INNER JOIN inventoryitems as item - ON item.id = trip.item_id - INNER JOIN inventoryitemcategories as category - ON category.id = item.category_id - WHERE trip.trip_id = ?; + category.description AS category_description, + inner.trip_id AS trip_id, + inner.category_description AS category_description, + inner.item_id AS item_id, + inner.item_name AS item_name, + inner.item_description AS item_description, + inner.item_weight AS item_weight, + inner.item_is_picked AS item_is_picked, + inner.item_is_packed AS item_is_packed + FROM inventory_items_categories AS category + LEFT JOIN ( + SELECT + trip.trip_id AS trip_id, + category.id as category_id, + category.name as category_name, + category.description as category_description, + item.id as item_id, + item.name as item_name, + item.description as item_description, + item.weight as item_weight, + trip.pick as item_is_picked, + trip.pack as item_is_packed + FROM trips_items as trip + INNER JOIN inventory_items as item + ON item.id = trip.item_id + INNER JOIN inventory_items_categories as category + ON category.id = item.category_id + WHERE trip.trip_id = 'a8b181d6-3b16-4a41-99fa-0713b94a34d9' + ) AS inner + ON inner.category_id = category.id ", ) .bind(self.id.to_string()) @@ -315,28 +332,38 @@ impl<'a> Trip { items: None, }; - let item = TripItem { - item: Item { - id: Uuid::try_parse(row.try_get("item_id")?)?, - name: row.try_get("item_name")?, - description: row.try_get("item_description")?, - weight: row.try_get("item_weight")?, - category_id: category.category.id, - }, - picked: row.try_get("item_is_picked")?, - packed: row.try_get("item_is_packed")?, - }; + match row.try_get("item_id")? { + None => { + // we have an empty (unused) category which has NULL values + // for the item_id column + category.items = Some(vec![]); + categories.push(category); + } + Some(item_id) => { + let item = TripItem { + item: Item { + id: Uuid::try_parse(item_id)?, + name: row.try_get("item_name")?, + description: row.try_get("item_description")?, + weight: row.try_get("item_weight")?, + category_id: category.category.id, + }, + picked: row.try_get("item_is_picked")?, + packed: row.try_get("item_is_packed")?, + }; - if let Some(&mut ref mut c) = categories - .iter_mut() - .find(|c| c.category.id == category.category.id) - { - // we always populate c.items when we add a new category, so - // it's safe to unwrap here - c.items.as_mut().unwrap().push(item); - } else { - category.items = Some(vec![item]); - categories.push(category); + if let Some(&mut ref mut c) = categories + .iter_mut() + .find(|c| c.category.id == category.category.id) + { + // we always populate c.items when we add a new category, so + // it's safe to unwrap here + c.items.as_mut().unwrap().push(item); + } else { + category.items = Some(vec![item]); + categories.push(category); + } + } } Ok(()) @@ -413,7 +440,7 @@ impl<'a> Category { let items = sqlx::query(&format!( "SELECT id,name,weight,description,category_id - FROM inventoryitems + FROM inventory_items WHERE category_id = '{id}'", id = self.id )) @@ -461,7 +488,7 @@ impl TryFrom for Item { impl Item { pub async fn find(pool: &sqlx::Pool, id: Uuid) -> Result, Error> { let item: Result, sqlx::Error> = sqlx::query( - "SELECT * FROM inventoryitems AS item + "SELECT * FROM inventory_items AS item WHERE item.id = ?", ) .bind(id.to_string()) @@ -485,12 +512,12 @@ impl Item { weight: u32, ) -> Result, Error> { let id: Result, sqlx::Error> = sqlx::query( - "UPDATE inventoryitems AS item + "UPDATE inventory_items AS item SET name = ?, weight = ? WHERE item.id = ? - RETURNING inventoryitems.category_id AS id + RETURNING inventory_items.category_id AS id ", ) .bind(name)