Compare commits

...

3 commits

Author SHA1 Message Date
Simon Bruder b8184c459b
Improve page title layout with long titles
All checks were successful
/ build (push) Successful in 6s
2024-07-24 12:20:23 +02:00
Simon Bruder 026f13e7e0
Use clap for configuration 2024-07-24 12:20:22 +02:00
Simon Bruder 2eb3b505e0
Add item state 2024-07-24 12:20:19 +02:00
32 changed files with 1169 additions and 250 deletions

View file

@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n WHERE items.original_packaging = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class_name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "02438c29c249ce2b446d15a41d07c40ce73946e62744a486cbbac256c4a4cf55"
}

View file

@ -0,0 +1,37 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO item_events (item, date, event, description)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Date",
{
"Custom": {
"name": "item_event",
"kind": {
"Enum": [
"acquire",
"borrow",
"buy",
"dispose",
"gift",
"loan",
"lose",
"recieve_gift",
"return_borrowed",
"return_loaned",
"sell",
"use"
]
}
}
},
"Varchar"
]
},
"nullable": []
},
"hash": "11f1edc0dce92fbd127159dbebd72b16de479d071f03433b480087bef99f5b1c"
}

View file

@ -0,0 +1,72 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n event AS \"event: ItemEvent\",\n next AS \"next: ItemState\"\n FROM item_events_transitions\n WHERE state = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "event: ItemEvent",
"type_info": {
"Custom": {
"name": "item_event",
"kind": {
"Enum": [
"acquire",
"borrow",
"buy",
"dispose",
"gift",
"loan",
"lose",
"recieve_gift",
"return_borrowed",
"return_loaned",
"sell",
"use"
]
}
}
}
},
{
"ordinal": 1,
"name": "next: ItemState",
"type_info": {
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
}
],
"parameters": {
"Left": [
{
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
]
},
"nullable": [
false,
false
]
},
"hash": "1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n WITH RECURSIVE cte AS (\n SELECT\n id,\n ARRAY[]::UUID[] AS parents,\n ARRAY[]::VARCHAR[] AS parent_names,\n ARRAY[]::VARCHAR[] AS parent_class_names\n FROM items\n WHERE parent IS NULL\n\n UNION\n\n SELECT\n items.id,\n cte.parents || items.parent,\n cte.parent_names || parent.name,\n cte.parent_class_names || parent_class.name\n FROM cte\n JOIN items\n ON items.parent = cte.id\n JOIN items AS \"parent\"\n ON parent.id = cte.id\n JOIN item_classes AS \"parent_class\"\n ON parent.class = parent_class.id\n )\n SELECT\n cte.id AS \"id!\",\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n cte.parents AS \"parents!\",\n cte.parent_names AS \"parent_names!: Vec<Option<String>>\",\n cte.parent_class_names AS \"parent_class_names!\"\n FROM cte\n JOIN items\n ON cte.id = items.id\n JOIN item_classes\n ON items.class = item_classes.id\n ORDER BY items.created_at\n ", "query": "\n WITH RECURSIVE cte AS (\n SELECT\n id,\n ARRAY[]::UUID[] AS parents,\n ARRAY[]::VARCHAR[] AS parent_names,\n ARRAY[]::VARCHAR[] AS parent_class_names\n FROM items\n WHERE parent IS NULL\n\n UNION\n\n SELECT\n items.id,\n cte.parents || items.parent,\n cte.parent_names || parent.name,\n cte.parent_class_names || parent_class.name\n FROM cte\n JOIN items\n ON items.parent = cte.id\n JOIN items AS \"parent\"\n ON parent.id = cte.id\n JOIN item_classes AS \"parent_class\"\n ON parent.class = parent_class.id\n )\n SELECT\n cte.id AS \"id!\",\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n cte.parents AS \"parents!\",\n cte.parent_names AS \"parent_names!: Vec<Option<String>>\",\n cte.parent_class_names AS \"parent_class_names!\",\n item_states.state AS \"state!: ItemState\"\n FROM cte\n JOIN items\n ON cte.id = items.id\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n ORDER BY items.created_at\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -37,6 +37,23 @@
"ordinal": 6, "ordinal": 6,
"name": "parent_class_names!", "name": "parent_class_names!",
"type_info": "VarcharArray" "type_info": "VarcharArray"
},
{
"ordinal": 7,
"name": "state!: ItemState",
"type_info": {
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
} }
], ],
"parameters": { "parameters": {
@ -49,8 +66,9 @@
false, false,
null, null,
null, null,
null null,
true
] ]
}, },
"hash": "3dedb7b184103c1d418f7b94e26c75aea0c7d22e009299d1b87443e350578171" "hash": "3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee"
} }

View file

@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n WHERE items.class = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class_name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "482df01f0509cc8ec18ffe1caea8f65f11c170b67424e35697540ae12e577227"
}

View file

@ -0,0 +1,106 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n items.id,\n items.short_id,\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n items.original_packaging,\n op.name AS \"original_packaging_name?\",\n op_class.name AS \"original_packaging_class_name?\",\n op_state.state AS \"original_packaging_state: ItemState\",\n items.description,\n item_states.state AS \"state!: ItemState\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n LEFT JOIN items AS \"op\"\n ON items.original_packaging = op.id\n LEFT JOIN item_classes AS \"op_class\"\n ON op.class = op_class.id\n LEFT JOIN item_states AS \"op_state\"\n ON op.id = op_state.item\n WHERE items.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "original_packaging_name?",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "original_packaging_class_name?",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "original_packaging_state: ItemState",
"type_info": {
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
},
{
"ordinal": 9,
"name": "description",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "state!: ItemState",
"type_info": {
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
true,
false,
false,
true,
true,
false,
true,
false,
true
]
},
"hash": "8683674fed595842f0369f2dafbc11c7f9f031931a5a4f8b43f3ffa1b8b6d10b"
}

View file

@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n items.id,\n items.name,\n item_classes.name AS \"class_name\",\n item_states.state AS \"state!: ItemState\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n WHERE items.parent = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "state!: ItemState",
"type_info": {
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false,
true
]
},
"hash": "915945504790c0295800c3e5dae1d38dba1c47e9be6cbf120313263336b8ca21"
}

View file

@ -1,70 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n items.id,\n items.short_id,\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n items.original_packaging,\n op.name AS \"original_packaging_name?\",\n op_class.name AS \"original_packaging_class_name?\",\n items.description\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n LEFT JOIN items AS \"op\"\n ON items.original_packaging = op.id\n LEFT JOIN item_classes AS \"op_class\"\n ON op.class = op_class.id\n WHERE items.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "original_packaging_name?",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "original_packaging_class_name?",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
true,
false,
false,
true,
true,
false,
false
]
},
"hash": "9c1a1e9c33539f6ec4800c9e8fc8ce3b65f49c34ad848321dc1b151c21cb0043"
}

View file

@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n WHERE items.parent = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class_name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "9e1704d0b60906061460e2c972fccf5d87b053857c161651360e5a8fdd80e397"
}

View file

@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n items.id,\n items.name,\n item_classes.name AS \"class_name\",\n item_states.state AS \"state!: ItemState\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n WHERE items.class = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "state!: ItemState",
"type_info": {
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false,
true
]
},
"hash": "a0ac133f1dce54853319b776ff6e7d3f186dc380e091858f85950719d73eee67"
}

View file

@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n items.id,\n items.name,\n item_classes.name AS \"class_name\",\n item_states.state AS \"state!: ItemState\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n WHERE items.original_packaging = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "state!: ItemState",
"type_info": {
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false,
true
]
},
"hash": "cad9b7d82b518e4a88b62fa5ea335a2451af8fafeab425868d7196ed9f078874"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM item_events WHERE id = $1 RETURNING item",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "item",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false
]
},
"hash": "ce9d4ef7aff0cfbbace291d10e459771013e090463d7b858a26df1295e31184a"
}

View file

@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, date, event AS \"event: ItemEvent\", description FROM item_events WHERE item = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "date",
"type_info": "Date"
},
{
"ordinal": 2,
"name": "event: ItemEvent",
"type_info": {
"Custom": {
"name": "item_event",
"kind": {
"Enum": [
"acquire",
"borrow",
"buy",
"dispose",
"gift",
"loan",
"lose",
"recieve_gift",
"return_borrowed",
"return_loaned",
"sell",
"use"
]
}
}
}
},
{
"ordinal": 3,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "f700f5e4657cfb3d572e9f11c23d408c8f31c8d75737398429e256a167c91d68"
}

55
Cargo.lock generated
View file

@ -540,6 +540,46 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "clap"
version = "4.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f6b81fb3c84f5563d509c59b5a48d935f689e993afa90fe39047f05adef9142"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca6706fd5224857d9ac5eb9355f6683563cc0541c7cd9d014043b57cbec78ac"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.68",
]
[[package]]
name = "clap_lex"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.1" version = "1.0.1"
@ -1016,6 +1056,12 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
@ -1170,6 +1216,7 @@ dependencies = [
"actix-web", "actix-web",
"barcoders", "barcoders",
"base64 0.22.1", "base64 0.22.1",
"clap",
"datamatrix", "datamatrix",
"enum-iterator", "enum-iterator",
"env_logger", "env_logger",
@ -2042,7 +2089,7 @@ checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8"
dependencies = [ dependencies = [
"dotenvy", "dotenvy",
"either", "either",
"heck", "heck 0.4.1",
"hex", "hex",
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
@ -2180,6 +2227,12 @@ dependencies = [
"unicode-properties", "unicode-properties",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"

View file

@ -15,6 +15,7 @@ actix-session = { version = "0.9.0", features = ["cookie-session"] }
actix-web = { version = "4.8.0", features = ["cookies"] } actix-web = { version = "4.8.0", features = ["cookies"] }
barcoders = { version = "2.0.0", default-features = false, features = ["std"] } barcoders = { version = "2.0.0", default-features = false, features = ["std"] }
base64 = "0.22.1" base64 = "0.22.1"
clap = { version = "4.5.10", features = ["derive", "env"] }
datamatrix = "0.3.1" datamatrix = "0.3.1"
enum-iterator = "2.1.0" enum-iterator = "2.1.0"
env_logger = "0.11.3" env_logger = "0.11.3"
@ -31,7 +32,7 @@ serde_urlencoded = "0.7.1"
serde_variant = "0.1.3" serde_variant = "0.1.3"
sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] } sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] }
thiserror = "1.0.61" thiserror = "1.0.61"
time = { version = "0.3.36", features = ["serde"] } time = { version = "0.3.36", features = ["parsing", "serde"] }
uuid = { version = "1.9.0", features = ["serde", "v4"] } uuid = { version = "1.9.0", features = ["serde", "v4"] }
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]

View file

@ -105,6 +105,7 @@
cargo-deny cargo-deny
cargo-watch cargo-watch
clippy clippy
graphviz
postgresql.lib postgresql.lib
postgresql postgresql
reuse reuse

View file

@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
digraph item_status {
rankdir=LR;
node [shape=point]; q0;
node [shape=circle]; owned loaned borrowed inactive;
q0 -> inactive;
// s/\(.*\) -> \(.*\) \[label="\(.*\)"\];/('\1', '\3', '\2'),/
inactive -> owned [label="buy"];
inactive -> owned [label="recieve_gift"];
inactive -> owned [label="acquire"]; // generic, also used as fallback for older items
inactive -> borrowed [label="borrow"];
owned -> inactive [label="sell"];
owned -> inactive [label="gift"];
owned -> inactive [label="lose"];
owned -> inactive [label="use"];
owned -> inactive [label="dispose"];
owned -> loaned [label="loan"];
loaned -> owned [label="return_loaned"];
borrowed -> inactive [label="return_borrowed"];
}

View file

@ -0,0 +1,20 @@
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later
DROP TRIGGER add_default_item_event ON items;
DROP FUNCTION add_default_item_event;
DROP VIEW item_states;
DROP TRIGGER check_item_events_delete ON item_events;
DROP FUNCTION check_item_events_delete;
DROP TRIGGER check_item_events_fsm ON item_events;
DROP FUNCTION check_item_events_fsm;
DROP TABLE item_events;
DROP AGGREGATE item_events_fsm(item_event);
DROP FUNCTION item_events_transition;
DROP TABLE item_events_transitions;
DROP TYPE item_state;
DROP TYPE item_event;

View file

@ -0,0 +1,153 @@
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- This datbase design is inspired by the following two blog posts by Felix Geisendörfer and Raphael Medaer:
--
-- https://felixge.de/2017/07/27/implementing-state-machines-in-postgresql/
-- https://raphael.medaer.me/2019/06/12/pgfsm.html
--
-- I chose a design that makes a compromise between both designs.
-- To simplify modifying transitions, it uses a table for storing the transitions,
-- but it does not version it, as the design should not radically change.
CREATE TYPE item_state AS ENUM (
'borrowed',
'inactive',
'loaned',
'owned'
);
CREATE TYPE item_event AS ENUM (
'acquire',
'borrow',
'buy',
'dispose',
'gift',
'loan',
'lose',
'recieve_gift',
'return_borrowed',
'return_loaned',
'sell',
'use'
);
CREATE TABLE item_events_transitions (
state item_state,
event item_event,
next item_state,
PRIMARY KEY (state, event, next)
);
INSERT INTO item_events_transitions VALUES
('inactive', 'buy', 'owned'),
('inactive', 'recieve_gift', 'owned'),
('inactive', 'acquire', 'owned'),
('inactive', 'borrow', 'borrowed'),
('owned', 'sell', 'inactive'),
('owned', 'gift', 'inactive'),
('owned', 'lose', 'inactive'),
('owned', 'use', 'inactive'),
('owned', 'dispose', 'inactive'),
('owned', 'loan', 'loaned'),
('loaned', 'return_loaned', 'owned'),
('borrowed', 'return_borrowed', 'inactive');
CREATE FUNCTION item_events_transition(_state item_state, _event item_event)
RETURNS item_state AS $$
SELECT next
FROM item_events_transitions
WHERE state = _state AND event = _event;
$$ LANGUAGE sql STRICT;
CREATE AGGREGATE item_events_fsm(item_event) (
SFUNC = item_events_transition,
STYPE = item_state,
INITCOND = 'inactive'
);
CREATE TABLE item_events (
id SERIAL PRIMARY KEY,
item UUID NOT NULL REFERENCES items(id),
date DATE NOT NULL DEFAULT now(),
event item_event NOT NULL,
description VARCHAR NOT NULL DEFAULT ''
);
CREATE FUNCTION check_item_events_fsm()
RETURNS trigger AS $$
BEGIN
IF (SELECT item_events_fsm(event ORDER BY id) FROM (
SELECT id, event FROM item_events WHERE item = NEW.item
UNION ALL
SELECT NEW.id, NEW.event
) events) IS NULL THEN
RAISE EXCEPTION 'Event not possible from current state';
END IF;
RETURN NEW;
END
$$ LANGUAGE plpgsql;
CREATE TRIGGER check_item_events_fsm
BEFORE INSERT ON item_events
FOR EACH ROW
EXECUTE PROCEDURE check_item_events_fsm();
CREATE FUNCTION check_item_events_delete()
RETURNS trigger AS $$
BEGIN
IF (SELECT OLD.id <> max(id) FROM item_events WHERE item = OLD.item) THEN
RAISE EXCEPTION 'Only the last event of an item can be deleted';
END IF;
RETURN OLD;
END
$$ LANGUAGE plpgsql;
CREATE TRIGGER check_item_events_delete
BEFORE DELETE ON item_events
FOR EACH ROW
EXECUTE PROCEDURE check_item_events_delete();
CREATE VIEW item_states AS
-- probably not the best query, but it works
SELECT
items.id AS "item",
state,
event AS "last_event",
date AS "last_event_date",
item_events.description AS "last_event_description"
FROM item_events
-- items without eny event must be included
RIGHT JOIN items
ON item_events.item = items.id
JOIN (
SELECT
item_events_fsm(event ORDER BY item_events.id) AS "state",
max(item_events.id) AS "id",
items.id AS "item"
FROM item_events
-- see above
RIGHT JOIN items
ON item_events.item = items.id
GROUP BY item_events.item, items.id
) last_event
ON items.id = last_event.item AND (item_events.id = last_event.id OR last_event.id IS NULL);
CREATE FUNCTION add_default_item_event()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO item_events (item, event, description)
VALUES (NEW.id, 'acquire', 'automatically added on item insert');
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER add_default_item_event
AFTER INSERT ON items
FOR EACH ROW
EXECUTE FUNCTION add_default_item_event();
INSERT INTO item_events (item, event, description)
SELECT id, 'acquire', 'automatically added on migration' FROM items;

34
src/config.rs Normal file
View file

@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::net::{IpAddr, Ipv6Addr};
use clap::Parser;
/// A lightweight inventory management system
#[derive(Clone, Parser, Debug)]
#[command(version, about)]
pub struct Config {
/// Database URL of PostgreSQL database
#[arg(long, env)]
pub database_url: String,
/// Secret key for encrypting session cookie
///
/// Can be generated with head -c 64 /dev/urandom | base64 -w0
#[arg(long, env)]
pub secret_key: Option<String>,
/// Address for HTTP server to listen on
#[arg(long, env, default_value_t = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)))]
pub listen_address: std::net::IpAddr,
/// Port for HTTP server to listen on
#[arg(long, env, default_value_t = 8080)]
pub listen_port: u16,
/// Superuser password
#[arg(long, env)]
pub superuser_password: String,
}

View file

@ -59,12 +59,10 @@ async fn login(
req: HttpRequest, req: HttpRequest,
form: web::Form<LoginForm>, form: web::Form<LoginForm>,
query: web::Query<LoginQuery>, query: web::Query<LoginQuery>,
config: web::Data<crate::Config>,
) -> Result<impl Responder, error::Error> { ) -> Result<impl Responder, error::Error> {
// Very basic authentication for now (only password, hardcoded in environment variable) // Very basic authentication for now (only password, hardcoded in configuration)
if form.password if form.password == config.superuser_password {
== std::env::var("SUPERUSER_PASSWORD")
.map_err(|_| error::ErrorInternalServerError("login disabled (no password set)"))?
{
Identity::login(&req.extensions(), "superuser".into()) Identity::login(&req.extensions(), "superuser".into())
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
Ok( Ok(

View file

@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::Identity;
use actix_web::{error, post, web, Responder};
use serde::Deserialize;
use sqlx::{query, query_scalar, PgPool};
use time::Date;
use uuid::Uuid;
use crate::frontend::templates::helpers::ItemEvent;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(delete).service(add);
}
// not the best HTTP method, but there is no (non-JS) way of sending a DELETE request
#[post("/items/event/{id}/delete")]
async fn delete(
pool: web::Data<PgPool>,
path: web::Path<i32>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_id = query_scalar!("DELETE FROM item_events WHERE id = $1 RETURNING item", id)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to(format!("/item/{}", item_id)).see_other())
}
#[derive(Deserialize)]
struct NewEvent {
date: Date,
event: ItemEvent,
description: String,
}
#[post("/item/{id}/events/add")]
async fn add(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
data: web::Form<NewEvent>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let data = data.into_inner();
query!(
"INSERT INTO item_events (item, date, event, description)
VALUES ($1, $2, $3, $4)",
id,
data.date,
data.event as ItemEvent,
data.description
)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to(format!("/item/{}", id)).see_other())
}

View file

@ -10,7 +10,9 @@ use uuid::Uuid;
use crate::frontend::templates::{ use crate::frontend::templates::{
self, self,
helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod}, helpers::{
Colour, ItemName, ItemPreview, ItemState, PageAction, PageActionGroup, PageActionMethod,
},
TemplateConfig, TemplateConfig,
}; };
@ -24,6 +26,7 @@ struct ItemListEntry {
class: Uuid, class: Uuid,
class_name: String, class_name: String,
parents: Vec<ItemPreview>, parents: Vec<ItemPreview>,
state: ItemState,
} }
#[get("/items")] #[get("/items")]
@ -61,12 +64,15 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
item_classes.name AS "class_name", item_classes.name AS "class_name",
cte.parents AS "parents!", cte.parents AS "parents!",
cte.parent_names AS "parent_names!: Vec<Option<String>>", cte.parent_names AS "parent_names!: Vec<Option<String>>",
cte.parent_class_names AS "parent_class_names!" cte.parent_class_names AS "parent_class_names!",
item_states.state AS "state!: ItemState"
FROM cte FROM cte
JOIN items JOIN items
ON cte.id = items.id ON cte.id = items.id
JOIN item_classes JOIN item_classes
ON items.class = item_classes.id ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
ORDER BY items.created_at ORDER BY items.created_at
"# "#
) )
@ -78,6 +84,7 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
parents: itertools::izip!(row.parents, row.parent_names, row.parent_class_names) parents: itertools::izip!(row.parents, row.parent_names, row.parent_class_names)
.map(|(id, name, class_name)| ItemPreview::from_parts(id, name.as_ref(), &class_name)) .map(|(id, name, class_name)| ItemPreview::from_parts(id, name.as_ref(), &class_name))
.collect(), .collect(),
state: row.state,
}) })
.fetch_all(pool.as_ref()) .fetch_all(pool.as_ref())
.await .await
@ -106,6 +113,7 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
thead { thead {
tr { tr {
th { "Name" } th { "Name" }
th { "State" }
th { "Class" } th { "Class" }
th { "Parents" } th { "Parents" }
} }
@ -114,6 +122,7 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
@for item in items { @for item in items {
tr { tr {
td { (ItemPreview::new(item.id, item.name.clone().terse())) } td { (ItemPreview::new(item.id, item.name.clone().terse())) }
td { (item.state) }
td { a href={ "/item-class/" (item.class) } { (item.class_name) } } td { a href={ "/item-class/" (item.class) } { (item.class_name) } }
td { (templates::helpers::parents_breadcrumb(item.name, &item.parents, false)) } td { (templates::helpers::parents_breadcrumb(item.name, &item.parents, false)) }
} }

View file

@ -5,6 +5,7 @@
mod add; mod add;
mod delete; mod delete;
mod edit; mod edit;
mod event;
mod list; mod list;
mod show; mod show;
@ -14,6 +15,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.configure(add::config) cfg.configure(add::config)
.configure(delete::config) .configure(delete::config)
.configure(edit::config) .configure(edit::config)
.configure(event::config)
.configure(list::config) .configure(list::config)
.configure(show::config); .configure(show::config);
} }

View file

@ -5,12 +5,17 @@
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, web, Responder}; use actix_web::{error, get, web, Responder};
use maud::html; use maud::html;
use sqlx::{query, PgPool}; use serde_variant::to_variant_name;
use sqlx::{query, query_as, PgPool};
use time::{Date, OffsetDateTime};
use uuid::Uuid; use uuid::Uuid;
use crate::frontend::templates::{ use crate::frontend::templates::{
self, self, forms,
helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod}, helpers::{
Colour, ItemEvent, ItemName, ItemPreview, ItemState, PageAction, PageActionGroup,
PageActionMethod,
},
TemplateConfig, TemplateConfig,
}; };
@ -26,6 +31,14 @@ struct ItemDetails {
class_name: String, class_name: String,
original_packaging: Option<ItemPreview>, original_packaging: Option<ItemPreview>,
description: String, description: String,
state: ItemState,
}
struct ItemEventDetails {
id: i32,
date: Date,
event: ItemEvent,
description: String,
} }
#[get("/item/{id}")] #[get("/item/{id}")]
@ -46,14 +59,20 @@ async fn get(
items.original_packaging, items.original_packaging,
op.name AS "original_packaging_name?", op.name AS "original_packaging_name?",
op_class.name AS "original_packaging_class_name?", op_class.name AS "original_packaging_class_name?",
items.description op_state.state AS "original_packaging_state: ItemState",
items.description,
item_states.state AS "state!: ItemState"
FROM items FROM items
JOIN item_classes JOIN item_classes
ON items.class = item_classes.id ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
LEFT JOIN items AS "op" LEFT JOIN items AS "op"
ON items.original_packaging = op.id ON items.original_packaging = op.id
LEFT JOIN item_classes AS "op_class" LEFT JOIN item_classes AS "op_class"
ON op.class = op_class.id ON op.class = op_class.id
LEFT JOIN item_states AS "op_state"
ON op.id = op_state.item
WHERE items.id = $1"#, WHERE items.id = $1"#,
id id
) )
@ -69,13 +88,37 @@ async fn get(
row.original_packaging_name.as_ref(), row.original_packaging_name.as_ref(),
&row.original_packaging_class_name.unwrap(), &row.original_packaging_class_name.unwrap(),
) )
.with_state(row.original_packaging_state.unwrap())
}), }),
description: row.description, description: row.description,
state: row.state,
}) })
.fetch_one(pool.as_ref()) .fetch_one(pool.as_ref())
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
let events = query_as!(
ItemEventDetails,
r#"SELECT id, date, event AS "event: ItemEvent", description FROM item_events WHERE item = $1"#,
id
)
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let possible_events = query!(
r#"SELECT
event AS "event: ItemEvent",
next AS "next: ItemState"
FROM item_events_transitions
WHERE state = $1"#,
item.state as ItemState
)
.map(|row| (row.event, row.next))
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let parents = query!( let parents = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name" r#"SELECT items.id, items.name, item_classes.name AS "class_name"
FROM items FROM items
@ -93,27 +136,43 @@ async fn get(
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
let children = query!( let children = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name" r#"SELECT
items.id,
items.name,
item_classes.name AS "class_name",
item_states.state AS "state!: ItemState"
FROM items FROM items
JOIN item_classes JOIN item_classes
ON items.class = item_classes.id ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
WHERE items.parent = $1"#, WHERE items.parent = $1"#,
id id
) )
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name)) .map(|row| {
ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state)
})
.fetch_all(pool.as_ref()) .fetch_all(pool.as_ref())
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
let original_packaging_of = query!( let original_packaging_of = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name" r#"SELECT
items.id,
items.name,
item_classes.name AS "class_name",
item_states.state AS "state!: ItemState"
FROM items FROM items
JOIN item_classes JOIN item_classes
ON items.class = item_classes.id ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
WHERE items.original_packaging = $1"#, WHERE items.original_packaging = $1"#,
id id
) )
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name)) .map(|row| {
ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state)
})
.fetch_all(pool.as_ref()) .fetch_all(pool.as_ref())
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
@ -126,6 +185,7 @@ async fn get(
path: &format!("/item/{}", item.id), path: &format!("/item/{}", item.id),
title: Some(&title), title: Some(&title),
page_title: Some(Box::new(item.name.clone())), page_title: Some(Box::new(item.name.clone())),
page_title_extra: Some(Box::new(item.state)),
page_actions: vec![ page_actions: vec![
(PageActionGroup::Button { (PageActionGroup::Button {
action: PageAction { action: PageAction {
@ -157,40 +217,122 @@ async fn get(
..Default::default() ..Default::default()
}, },
html! { html! {
table .table { div .row {
tr { div .col-md-8 {
th { "UUID" } table .table {
td { (item.id) } tr {
} th { "UUID" }
tr { td { (item.id) }
th { "Short ID" } }
td { (item.short_id) } tr {
} th { "Short ID" }
tr { td { (item.short_id) }
th { "Name" } }
td { (item.name.clone().terse()) } tr {
} th { "Name" }
tr { td { (item.name.clone().terse()) }
th { "Class" } }
td { a href={ "/item-class/" (item.class) } { (item.class_name) } } tr {
} th { "Class" }
tr { td { a href={ "/item-class/" (item.class) } { (item.class_name) } }
th { "Parents" } }
td { (templates::helpers::parents_breadcrumb(item.name, &parents, true)) } tr {
} th { "Parents" }
tr { td { (templates::helpers::parents_breadcrumb(item.name, &parents, true)) }
th { "Original Packaging" } }
td { tr {
@if let Some(original_packaging) = item.original_packaging { th { "Original Packaging" }
(original_packaging) td {
} @else { @if let Some(original_packaging) = item.original_packaging {
"-" (original_packaging)
} @else {
"-"
}
}
}
tr {
th { "Description" }
td style="white-space: pre-wrap" { (item.description) }
} }
} }
} }
tr { div .col-md-4 {
th { "Description" } div .card {
td style="white-space: pre-wrap" { (item.description) } div .card-header {
"Events"
}
ul .list-group.list-group-flush {
@for (idx, event) in events.iter().enumerate() {
li .list-group-item {
div .d-flex.justify-content-between.align-items-start.mb-2[!event.description.is_empty()] {
strong { (event.event) }
span .badge.text-bg-secondary { (event.date) }
}
@if idx + 1 == events.len() {
form .float-end action={ "/items/event/" (event.id) "/delete" } method="POST" {
button .btn.text-bg-danger.btn-sm type="submit" { "Delete" }
}
}
@if !event.description.is_empty() {
p .mb-0 { (event.description) }
}
}
}
@if events.is_empty() {
li .list-group-item.text-secondary { "no events" }
}
}
div .card-body {
div .d-flex.gap-1.flex-wrap {
@for (event, next) in &possible_events {
button
.btn.(next.colour().button())
type="button"
data-bs-toggle="modal"
data-bs-target="#add-event-modal"
data-event-type=(to_variant_name(event).unwrap())
{ (event) }
}
}
}
}
}
div .modal #add-event-modal tabindex="-1" {
div .modal-dialog {
form .modal-content action={ "/item/" (id) "/events/add" } method="POST" {
div .modal-header {
h5 .modal-title { "Add event" }
button .btn-close type="button" data-bs-dismiss="modal";
}
div .modal-body {
div .mb-3 {
select .form-select name="event" id="event" {
@for (event, _) in &possible_events {
option value=(to_variant_name(event).unwrap()) { (event) }
}
}
}
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "description",
title: "Description",
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Date,
name: "date",
title: "Date",
value: Some(&OffsetDateTime::now_utc().date()),
required: true,
..Default::default()
})
}
div .modal-footer {
button .btn.btn-secondary type="button" data-bs-dismiss="modal" { "Cancel" }
button .btn.btn-primary type="submit" { "Add" }
}
}
}
} }
} }

View file

@ -11,7 +11,8 @@ use uuid::Uuid;
use crate::frontend::templates::{ use crate::frontend::templates::{
self, self,
helpers::{ helpers::{
Colour, ItemClassPreview, ItemPreview, PageAction, PageActionGroup, PageActionMethod, Colour, ItemClassPreview, ItemPreview, ItemState, PageAction, PageActionGroup,
PageActionMethod,
}, },
TemplateConfig, TemplateConfig,
}; };
@ -70,14 +71,22 @@ async fn get(
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
let items = query!( let items = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name" r#"SELECT
items.id,
items.name,
item_classes.name AS "class_name",
item_states.state AS "state!: ItemState"
FROM items FROM items
JOIN item_classes JOIN item_classes
ON items.class = item_classes.id ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
WHERE items.class = $1"#, WHERE items.class = $1"#,
id id
) )
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name)) .map(|row| {
ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state)
})
.fetch_all(pool.as_ref()) .fetch_all(pool.as_ref())
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;

View file

@ -10,6 +10,7 @@ use super::datalist::Datalist;
#[derive(Clone)] #[derive(Clone)]
pub enum InputType { pub enum InputType {
Date,
Text, Text,
Textarea, Textarea,
} }
@ -17,6 +18,7 @@ pub enum InputType {
impl Display for InputType { impl Display for InputType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Date => write!(f, "date"),
Self::Text => write!(f, "text"), Self::Text => write!(f, "text"),
Self::Textarea => write!(f, "textarea"), Self::Textarea => write!(f, "textarea"),
} }

View file

@ -6,6 +6,7 @@ use std::fmt::{self, Display};
use crate::label::LabelPreset; use crate::label::LabelPreset;
use maud::{html, Markup, PreEscaped, Render}; use maud::{html, Markup, PreEscaped, Render};
use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
pub enum Css<'a> { pub enum Css<'a> {
@ -78,9 +79,13 @@ impl fmt::Display for Colour {
} }
impl Colour { impl Colour {
fn button(&self) -> String { pub fn button(&self) -> String {
format!("btn-{self}") format!("btn-{self}")
} }
pub fn text_bg(&self) -> String {
format!("text-bg-{self}")
}
} }
#[derive(Clone)] #[derive(Clone)]
@ -134,25 +139,39 @@ impl Render for ItemName {
pub struct ItemPreview { pub struct ItemPreview {
pub id: Uuid, pub id: Uuid,
pub name: ItemName, pub name: ItemName,
pub state: Option<ItemState>,
} }
impl ItemPreview { impl ItemPreview {
pub fn new(id: Uuid, name: ItemName) -> Self { pub fn new(id: Uuid, name: ItemName) -> Self {
Self { id, name } Self {
id,
name,
state: None,
}
} }
pub fn from_parts(id: Uuid, item_name: Option<&String>, class_name: &String) -> Self { pub fn from_parts(id: Uuid, item_name: Option<&String>, class_name: &String) -> Self {
Self { Self {
id, id,
name: ItemName::new(item_name, class_name), name: ItemName::new(item_name, class_name),
state: None,
} }
} }
pub fn with_state(mut self, state: ItemState) -> Self {
self.state = Some(state);
self
}
} }
impl Render for ItemPreview { impl Render for ItemPreview {
fn render(&self) -> Markup { fn render(&self) -> Markup {
html! { html! {
a href={ "/item/" (self.id) } { (self.name) } a .me-1[self.state.is_some()] href={ "/item/" (self.id) } { (self.name) }
@if let Some(ref state) = self.state {
(state)
}
} }
} }
} }
@ -279,3 +298,77 @@ pub fn parents_breadcrumb(name: ItemName, parents: &[ItemPreview], full: bool) -
} }
} }
} }
// TODO: Is this module the right place for ItemState and ItemEvent?
#[derive(Clone, Copy, Debug, sqlx::Type)]
#[sqlx(rename_all = "snake_case", type_name = "item_state")]
pub enum ItemState {
Borrowed,
Inactive,
Loaned,
Owned,
}
impl ItemState {
pub fn colour(&self) -> Colour {
match self {
ItemState::Borrowed => Colour::Warning,
ItemState::Inactive => Colour::Secondary,
ItemState::Loaned => Colour::Danger,
ItemState::Owned => Colour::Primary,
}
}
}
impl Render for ItemState {
fn render(&self) -> Markup {
html! {
span .badge.(self.colour().text_bg()) {
@match self {
ItemState::Borrowed => "borrowed",
ItemState::Inactive => "inactive",
ItemState::Loaned => "loaned",
ItemState::Owned => "owned",
}
}
}
}
}
#[derive(Debug, Deserialize, Serialize, sqlx::Type)]
#[sqlx(rename_all = "snake_case", type_name = "item_event")]
#[serde(rename_all = "snake_case")]
pub enum ItemEvent {
Acquire,
Borrow,
Buy,
Dispose,
Gift,
Loan,
Lose,
RecieveGift,
ReturnBorrowed,
ReturnLoaned,
Sell,
Use,
}
impl fmt::Display for ItemEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ItemEvent::Acquire => write!(f, "acquire"),
ItemEvent::Borrow => write!(f, "borrow"),
ItemEvent::Buy => write!(f, "buy"),
ItemEvent::Dispose => write!(f, "dispose"),
ItemEvent::Gift => write!(f, "gift"),
ItemEvent::Loan => write!(f, "loan"),
ItemEvent::Lose => write!(f, "lose"),
ItemEvent::RecieveGift => write!(f, "recieve gift"),
ItemEvent::ReturnBorrowed => write!(f, "return borrowed"),
ItemEvent::ReturnLoaned => write!(f, "return loaned"),
ItemEvent::Sell => write!(f, "sell"),
ItemEvent::Use => write!(f, "use"),
}
}
}

View file

@ -68,6 +68,7 @@ pub struct TemplateConfig<'a> {
pub path: &'a str, pub path: &'a str,
pub title: Option<&'a str>, pub title: Option<&'a str>,
pub page_title: Option<Box<dyn Render + 'a>>, pub page_title: Option<Box<dyn Render + 'a>>,
pub page_title_extra: Option<Box<dyn Render + 'a>>,
pub page_actions: Vec<PageActionGroup>, pub page_actions: Vec<PageActionGroup>,
pub extra_css: Vec<Css<'a>>, pub extra_css: Vec<Css<'a>>,
pub extra_js: Vec<Js<'a>>, pub extra_js: Vec<Js<'a>>,
@ -81,6 +82,7 @@ impl Default for TemplateConfig<'_> {
path: "/", path: "/",
title: None, title: None,
page_title: None, page_title: None,
page_title_extra: None,
page_actions: Vec::new(), page_actions: Vec::new(),
extra_css: Vec::new(), extra_css: Vec::new(),
extra_js: Vec::new(), extra_js: Vec::new(),
@ -115,19 +117,24 @@ pub fn base(config: TemplateConfig, content: Markup) -> Markup {
(navbar(&config)) (navbar(&config))
main .container.my-4 { main .container.my-4 {
div .d-flex.justify-content-between.mb-3 { div {
div { div .float-end.d-flex.align-items-start.h-100.gap-1 {
@for page_action in config.page_actions {
(page_action)
}
}
div .d-flex.align-items-center.gap-1 {
@if let Some(ref page_title) = config.page_title { @if let Some(ref page_title) = config.page_title {
h2 { h2 {
(page_title) (page_title)
@if let Some(ref page_title_extra) = config.page_title_extra {
" "
(page_title_extra)
}
} }
} }
} }
div .d-flex.h-100.gap-1 {
@for page_action in config.page_actions {
(page_action)
}
}
} }
(content) (content)

View file

@ -2,6 +2,9 @@
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
mod config;
pub mod frontend; pub mod frontend;
pub mod label; pub mod label;
pub mod middleware; pub mod middleware;
pub use config::Config;

View file

@ -2,17 +2,18 @@
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use std::env;
use actix_identity::IdentityMiddleware; use actix_identity::IdentityMiddleware;
use actix_session::{storage::CookieSessionStore, SessionMiddleware}; use actix_session::{storage::CookieSessionStore, SessionMiddleware};
use actix_web::middleware::ErrorHandlers; use actix_web::middleware::ErrorHandlers;
use actix_web::{cookie::Key, http::StatusCode, web, App, HttpResponse, HttpServer}; use actix_web::{cookie::Key, http::StatusCode, web, App, HttpResponse, HttpServer};
use base64::prelude::{Engine as _, BASE64_STANDARD}; use base64::prelude::{Engine as _, BASE64_STANDARD};
use clap::Parser;
use log::{info, warn}; use log::{info, warn};
use mime_guess::from_path; use mime_guess::from_path;
use rust_embed::Embed; use rust_embed::Embed;
use li7y::Config;
#[derive(Embed)] #[derive(Embed)]
#[folder = "static"] #[folder = "static"]
struct Static; struct Static;
@ -21,39 +22,38 @@ struct Static;
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse();
// generate a secret key with head -c 64 /dev/urandom | base64 -w0 // generate a secret key with head -c 64 /dev/urandom | base64 -w0
let secret_key = match env::var("SECRET_KEY") { let secret_key = match config.secret_key {
Ok(encoded) => Key::from( Some(ref encoded) => Key::from(
&BASE64_STANDARD &BASE64_STANDARD
.decode(encoded) .decode(encoded)
.expect("failed to decode base64 in SECRET_KEY"), .expect("failed to decode base64 in SECRET_KEY"),
), ),
Err(_) => { None => {
warn!("SECRET_KEY was not specified, using randomly generated key"); warn!("SECRET_KEY was not specified, using randomly generated key");
Key::generate() Key::generate()
} }
}; };
let pool: sqlx::PgPool = sqlx::Pool::<sqlx::postgres::Postgres>::connect( let pool: sqlx::PgPool = sqlx::Pool::<sqlx::postgres::Postgres>::connect(&config.database_url)
&env::var("DATABASE_URL").expect("DATABASE_URL must be set"), .await
) .expect("failed to connect to database");
.await
.expect("failed to connect to database");
sqlx::migrate!() sqlx::migrate!()
.run(&pool) .run(&pool)
.await .await
.expect("failed to run migrations"); .expect("failed to run migrations");
let address = env::var("LISTEN_ADDRESS").unwrap_or("::1".to_string()); let address = config.listen_address;
let port = env::var("LISTEN_PORT").map_or(8080, |s| { let port = config.listen_port;
s.parse::<u16>().expect("failed to parse LISTEN_PORT")
});
info!("Starting on {address}:{port}"); info!("Starting on {address}:{port}");
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()
.app_data(web::Data::new(config.clone()))
.app_data(web::Data::new(pool.clone())) .app_data(web::Data::new(pool.clone()))
.service(web::scope("/static").route( .service(web::scope("/static").route(
"/{_:.*}", "/{_:.*}",

View file

@ -85,4 +85,8 @@
.join(",") .join(",")
}) })
} }
document.getElementById("add-event-modal").addEventListener("show.bs.modal", e => {
document.getElementById("event").value = e.relatedTarget.dataset.eventType
})
})() })()