diff --git a/migrations/2024-07-03-122248_item_add_parent/down.sql b/migrations/2024-07-03-122248_item_add_parent/down.sql new file mode 100644 index 0000000..3cdedd9 --- /dev/null +++ b/migrations/2024-07-03-122248_item_add_parent/down.sql @@ -0,0 +1,10 @@ +-- SPDX-FileCopyrightText: 2024 Simon Bruder +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +ALTER TABLE items + DROP parent; + +DROP VIEW item_tree; + +DROP FUNCTION check_item_cycle; diff --git a/migrations/2024-07-03-122248_item_add_parent/up.sql b/migrations/2024-07-03-122248_item_add_parent/up.sql new file mode 100644 index 0000000..a357bb6 --- /dev/null +++ b/migrations/2024-07-03-122248_item_add_parent/up.sql @@ -0,0 +1,50 @@ +-- SPDX-FileCopyrightText: 2024 Simon Bruder +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +ALTER TABLE items + ADD parent UUID REFERENCES items(id); + +CREATE RECURSIVE VIEW item_tree (id, parents) AS ( + SELECT id, ARRAY[]::UUID[] AS parents FROM items WHERE parent IS NULL + UNION + SELECT items.id, item_tree.parents || items.parent FROM items, item_tree WHERE items.parent = item_tree.id +); + +CREATE FUNCTION check_item_cycle() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.parent IS NULL THEN + RETURN NEW; + END IF; + + IF NEW.id = NEW.parent THEN + RAISE EXCEPTION 'Cycle detected'; + END IF; + + IF (WITH RECURSIVE cte AS ( + SELECT id, parent + FROM items + WHERE id = NEW.parent + + UNION + + SELECT items.id, items.parent + FROM items, cte + WHERE items.id = cte.parent + ) + SELECT 1 + FROM cte + WHERE parent = NEW.id + LIMIT 1) THEN + RAISE EXCEPTION 'Cycle detected'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER prevent_item_cycle +BEFORE INSERT OR UPDATE ON items +FOR EACH ROW +EXECUTE FUNCTION check_item_cycle(); diff --git a/src/api/v1/item.rs b/src/api/v1/item.rs index 4051cd5..8e584b5 100644 --- a/src/api/v1/item.rs +++ b/src/api/v1/item.rs @@ -13,7 +13,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(add) .service(list) .service(show) - .service(update); + .service(update) + .service(parents); } #[put("/item")] @@ -70,3 +71,17 @@ async fn update( Ok(HttpResponse::Ok().json(item)) } + +#[get("/item/{id}/parents")] +async fn parents( + pool: web::Data, + path: web::Path, +) -> actix_web::Result { + let id = path.into_inner(); + + let parents = web::block(move || manage::item::get_parents(&mut pool.get().unwrap(), id)) + .await? + .map_err(error::ErrorInternalServerError)?; + + Ok(HttpResponse::Ok().json(parents)) +} diff --git a/src/frontend/item.rs b/src/frontend/item.rs index e0e6f83..f40ee55 100644 --- a/src/frontend/item.rs +++ b/src/frontend/item.rs @@ -113,9 +113,10 @@ async fn edit_item_post( ) -> actix_web::Result { let id = path.into_inner(); - let item = web::block(move || manage::item::update(&mut pool.get().unwrap(), id, data.into_inner())) - .await? - .map_err(error::ErrorInternalServerError)?; + let item = + web::block(move || manage::item::update(&mut pool.get().unwrap(), id, data.into_inner())) + .await? + .map_err(error::ErrorInternalServerError)?; Ok(web::Redirect::to("/item/".to_owned() + &item.id.to_string()).see_other()) } diff --git a/src/manage/item.rs b/src/manage/item.rs index 81b386b..ecb092a 100644 --- a/src/manage/item.rs +++ b/src/manage/item.rs @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +use std::collections::HashMap; + use diesel::pg::PgConnection; use diesel::prelude::*; use uuid::Uuid; @@ -26,9 +28,43 @@ pub fn get_all(conn: &mut PgConnection) -> Result, diesel::result::Err schema::items::table.select(Item::as_select()).load(conn) } -pub fn update(conn: &mut PgConnection, id: Uuid, modified_item: NewItem) -> Result { +pub fn update( + conn: &mut PgConnection, + id: Uuid, + modified_item: NewItem, +) -> Result { diesel::update(schema::items::table.filter(schema::items::id.eq(id))) .set(modified_item) .returning(Item::as_returning()) .get_result(conn) } + +/// Helper type for querying parents of items +#[derive(Debug, Queryable, Selectable)] +#[diesel(table_name = schema::item_tree)] +#[diesel(check_for_backend(diesel::pg::Pg))] +struct ItemTreeMapping { + pub id: Uuid, + pub parents: Vec, +} + +pub fn get_parents(conn: &mut PgConnection, id: Uuid) -> Result, diesel::result::Error> { + schema::item_tree::table + .filter(schema::item_tree::id.eq(id)) + .select(ItemTreeMapping::as_select()) + .first(conn) + .map(|itm| itm.parents) +} + +pub fn get_all_parents( + conn: &mut PgConnection, +) -> Result>, diesel::result::Error> { + schema::item_tree::table + .select(ItemTreeMapping::as_select()) + .load(conn) + .map(|itms| { + itms.into_iter() + .map(|ItemTreeMapping { id, parents }| (id, parents)) + .collect::>>() + }) +} diff --git a/src/models.rs b/src/models.rs index 24827e4..26cdf84 100644 --- a/src/models.rs +++ b/src/models.rs @@ -14,6 +14,7 @@ use crate::schema::*; pub struct Item { pub id: Uuid, pub name: String, + pub parent: Option, } #[derive(Debug, Insertable, Deserialize, AsChangeset)] @@ -21,4 +22,6 @@ pub struct Item { #[diesel(check_for_backend(diesel::pg::Pg))] pub struct NewItem { pub name: String, + #[serde(default)] + pub parent: Option, } diff --git a/src/schema.rs b/src/schema.rs index 4837f55..12efc5c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -8,5 +8,13 @@ diesel::table! { items (id) { id -> Uuid, name -> Varchar, + parent -> Nullable, + } +} + +diesel::table! { + item_tree (id) { + id -> Uuid, + parents -> Array, } } diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..107a2a1 --- /dev/null +++ b/static/app.js @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +(() => { + // Allows a form checkbox to toggle an input element. + // This requires the form to have a structure like described in the bootstrap documentation: + // https://getbootstrap.com/docs/5.3/forms/input-group/#checkboxes-and-radios + Array.from(document.getElementsByClassName("input-toggle")).forEach(el => { + el.addEventListener("change", e => { + e.target.parentElement.parentElement.querySelector("input:not(.input-toggle)").disabled = !e.target.checked; + }) + }) +})() diff --git a/templates/base.html b/templates/base.html index 24217ad..ba58d23 100644 --- a/templates/base.html +++ b/templates/base.html @@ -55,5 +55,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later + + {# TODO this is not the best way, but it works for now #} + {% block extra_scripts %}{% endblock %} diff --git a/templates/item_add.html b/templates/item_add.html index cbcea21..ec01bca 100644 --- a/templates/item_add.html +++ b/templates/item_add.html @@ -12,6 +12,15 @@ SPDX-License-Identifier: AGPL-3.0-or-later +
+ +
+
+ +
+ +
+
{% endblock %} diff --git a/templates/item_details.html b/templates/item_details.html index bb1b888..8f4a28d 100644 --- a/templates/item_details.html +++ b/templates/item_details.html @@ -28,6 +28,14 @@ SPDX-License-Identifier: AGPL-3.0-or-later {{ item.name }} + + + Parent + + + {% if let Some(parent) = item.parent %}{{ parent }}{% else %}-{% endif %} + + {% endblock %} diff --git a/templates/item_edit.html b/templates/item_edit.html index 13c2d6f..b28744f 100644 --- a/templates/item_edit.html +++ b/templates/item_edit.html @@ -16,6 +16,15 @@ SPDX-License-Identifier: AGPL-3.0-or-later +
+ +
+
+ +
+ +
+
{% endblock %}