Compare commits

...

7 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
Simon Bruder 35230b6c37
flake: Update pre-commit-hooks.nix to new name
All checks were successful
/ build (push) Successful in 33s
This also removes all input overrides.
2024-07-24 12:19:59 +02:00
Simon Bruder faeec629a0
CI: Only gate OCI image push on branch 2024-07-24 12:19:58 +02:00
Simon Bruder 27ebe5770a
Remove home link from navbar
There is nothing there and it can also be reached by clicking on the
branding.
2024-07-24 12:19:58 +02:00
Simon Bruder e83bc8316e
Move away from models and manage subpackage
This architecture was started when the project still used Diesel.
Now that it uses SQLx, less things are done in Rust and more are done in
SQL. This commit now moves more of the query logic into SQL, which
should lead to more efficient queries and less moving data around.
2024-07-24 12:19:55 +02:00
75 changed files with 2098 additions and 1607 deletions

View file

@ -4,8 +4,6 @@
on:
push:
branches:
- master
jobs:
build:
@ -25,6 +23,7 @@ jobs:
- name: Build
run: nix build -L .#li7y .#li7y-oci
- name: Push OCI image
if: github.ref == 'refs/heads/master'
run: |
nix build .#li7y-oci
podman image load -i ./result

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,64 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM items WHERE parent = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "2e9619ce6db1047e2f5447c4925f28fd05f18706f70220b6ebb7354d2a0a9e3b"
}

View file

@ -0,0 +1,74 @@
{
"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 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": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "parents!",
"type_info": "UuidArray"
},
{
"ordinal": 5,
"name": "parent_names!: Vec<Option<String>>",
"type_info": "VarcharArray"
},
{
"ordinal": 6,
"name": "parent_class_names!",
"type_info": "VarcharArray"
},
{
"ordinal": 7,
"name": "state!: ItemState",
"type_info": {
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
true,
false,
false,
null,
null,
null,
true
]
},
"hash": "3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee"
}

View file

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id AS \"id?\", to_char(short_id, '000000') AS \"short_id?\"\n FROM items\n WHERE id = ANY ($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id?",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "short_id?",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
false,
null
]
},
"hash": "460f15b3c5ec2774c237fc581166bf44ecdb0b6145f8abba155478643a474125"
}

View file

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name FROM item_classes WHERE parent = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false
]
},
"hash": "4ed0c3101202abc920d81ee6acdaff9ed698f585df5e798d1f42206042e31a1b"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO items (name, parent, class, original_packaging, description)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"Uuid",
"Uuid",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "53be3ef02ba8b3deb3a74b184a4eb93e01fd0983c19aff3fca67bdc0afab4f37"
}

View file

@ -1,62 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM items ORDER BY created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "58969747bdccae4a2d3ad8d8117aa92283151d67f52fbb22e5d976b1c6a5c367"
}

View file

@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE items\n SET name = $2, parent = $3, class = $4, original_packaging = $5, description = $6\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Uuid",
"Uuid",
"Uuid",
"Varchar"
]
},
"nullable": []
},
"hash": "6737f54ceb18b9b744bac838801edbbdfe2cbf68a6346f93c072d47f5add9e46"
}

View file

@ -1,48 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO item_classes (name, parent, description) VALUES ($1, $2, $3) RETURNING *",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"Varchar"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "68d93c71840f87d5f5bb14b4d7fc34edd6be47cd1706b3612a332bbfd4bb54b4"
}

View file

@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT type as \"type!\" FROM\n (SELECT id, 'item' AS \"type\" FROM items\n UNION ALL\n SELECT id, 'item_class' AS \"type\" FROM item_classes) id_mapping\n WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "type!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "6dd8b63f4aaefbc1ab5d5a9bae2338a7275ba56a9b17278fd886175c3a27b0dd"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT type as \"type!\"\n FROM (SELECT id, 'item' AS \"type\" FROM items\n UNION ALL\n SELECT id, 'item_class' AS \"type\" FROM item_classes) id_mapping\n WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "type!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "719aff68ead7ada499158fec5e9f8f3c3841a4424da04aee0136c7e4f8df79e7"
}

View file

@ -1,64 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM items WHERE id = ANY ($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "79d8bfe2ed76ee550cdc31f282f598749d931af69a80d24f4575a4bc2c740f3b"
}

View file

@ -0,0 +1,32 @@
{
"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",
"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": []
},
"nullable": [
false,
true,
false
]
},
"hash": "7b3f478c9b217e09043daeca5dc574381493e559860258f4f6bffb12825b1ed7"
}

View file

@ -1,64 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM items WHERE short_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "7eeb752c8b00ac4000104f0254186f1f9fdb076e8f8b98f10fc1b981cfe8038c"
}

View file

@ -1,69 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE items SET name = $2, parent = $3, class = $4, original_packaging = $5, description = $6 WHERE id = $1 RETURNING *",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Uuid",
"Uuid",
"Uuid",
"Varchar"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "82df5c0633a655d376b8a91e9f11981cfee40fd04cb4e3552cc5f4ebf4ed0572"
}

View file

@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE item_classes\n SET name = $2, parent = $3, description = $4\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Uuid",
"Varchar"
]
},
"nullable": []
},
"hash": "835261895368c22af5780c4e4b69b518f5cb6936e3e4ea23c200b13787a401e1"
}

View file

@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO item_classes (name, parent, description)\n VALUES ($1, $2, $3)\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "83fe5e57b2c7db1fdfbf05749646ac6ebe71d81057cd865c5f946d6ddb62b552"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM item_classes WHERE parent = $1",
"query": "SELECT\n class.id,\n class.name,\n class.description,\n class.parent,\n parent.name AS \"parent_name?\"\n FROM item_classes AS \"class\"\n LEFT JOIN item_classes AS \"parent\"\n ON class.parent = parent.id\n WHERE class.id = $1",
"describe": {
"columns": [
{
@ -15,17 +15,17 @@
},
{
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "description",
"name": "parent_name?",
"type_info": "Varchar"
}
],
@ -37,10 +37,10 @@
"nullable": [
false,
false,
true,
false,
true,
false
]
},
"hash": "c552c0a40bc8995cb95726a85f1d0c0b86eb2322035e6a720e2e6d425072a8c1"
"hash": "84b4620db57dd9b963e09153c3de5938b3959ae41744098c4e9565404abf09ae"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO items (name, parent, class, original_packaging, description)\n SELECT * FROM UNNEST($1::VARCHAR[], $2::UUID[], $3::UUID[], $4::UUID[], $5::VARCHAR[])\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"VarcharArray",
"UuidArray",
"UuidArray",
"UuidArray",
"VarcharArray"
]
},
"nullable": [
false
]
},
"hash": "857bbaea0fa73c37679d927aa627ee841c5b89c86af12f32493ba05c0f0ead91"
}

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,64 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT items.*\n FROM items\n INNER JOIN\n unnest((SELECT parents FROM item_tree WHERE id = $1))\n WITH ORDINALITY AS parents(id, n)\n ON items.id = parents.id\n ORDER BY parents.n;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "9286b6ee0c08b8446ede68b890b8bf3208b55b51433ec92b4e7a452929a81945"
}

View file

@ -1,64 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM items WHERE class = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "94958a3a57c3178e6b0de5723b1fbc5433e972b5522b367098afe6cb90a30bf2"
}

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

@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id as \"id!\", parents as \"parents!\" FROM item_tree",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "parents!",
"type_info": "UuidArray"
}
],
"parameters": {
"Left": []
},
"nullable": [
true,
true
]
},
"hash": "a6f646089b4424deffc0b1a454bcfa2f2a497180e2517a937471f81a1f9c5538"
}

View file

@ -1,68 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO items (name, parent, class, original_packaging, description)\n SELECT * FROM UNNEST($1::VARCHAR[], $2::UUID[], $3::UUID[], $4::UUID[], $5::VARCHAR[])\n RETURNING *",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"VarcharArray",
"UuidArray",
"UuidArray",
"UuidArray",
"VarcharArray"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "b54d323092a1dc2e34d3882a06b3cd119362d0baf5effbbd09f969ee31f385ef"
}

View file

@ -1,68 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO items (name, parent, class, original_packaging, description) VALUES ($1, $2, $3, $4, $5) RETURNING *",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"Uuid",
"Uuid",
"Varchar"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "b80bb07ab582b0705b5c0370066730edf887d66a4196a0834c59f0df9f9314d3"
}

View file

@ -1,45 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM items WHERE id = $1",
"query": "SELECT\n items.name,\n items.parent,\n items.class,\n item_classes.name AS \"class_name\",\n items.original_packaging,\n items.description\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n WHERE items.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"ordinal": 1,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"ordinal": 2,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"ordinal": 5,
"name": "description",
"type_info": "Varchar"
}
@ -50,15 +40,13 @@
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "1e9045f52c002a19b815351fee1c7ee7520478ff4f5886b4523fb0dc4df0e204"
"hash": "c49b88eda9a62743783bc894f01bb6198594f94a3e0856abde0efdb4e49dbab8"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM item_classes ORDER BY created_at",
"query": "SELECT class.id, class.name, class.parent, parent.name AS \"parent_name?\"\n FROM item_classes AS \"class\"\n LEFT JOIN item_classes AS \"parent\"\n ON class.parent = parent.id\n ORDER BY class.created_at\n ",
"describe": {
"columns": [
{
@ -20,12 +20,7 @@
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "description",
"name": "parent_name?",
"type_info": "Varchar"
}
],
@ -36,9 +31,8 @@
false,
false,
true,
false,
false
]
},
"hash": "6e7b3389c47091d9fc8c7638b401b413f804c6f3e082a818b67ebab0938acb39"
"hash": "c7e35dee5f56da8da3c083e191f396b95917b15768ebe0250ce7840391036616"
}

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

@ -1,22 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT unnest(parents) as \"parents!\" FROM item_tree WHERE id = $1",
"query": "DELETE FROM item_events WHERE id = $1 RETURNING item",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "parents!",
"name": "item",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
"Int4"
]
},
"nullable": [
null
false
]
},
"hash": "97d6a7ee24e75dc5a9dc41a581e1013767fe36575c28574733c5ab5cbf557fb5"
"hash": "ce9d4ef7aff0cfbbace291d10e459771013e090463d7b858a26df1295e31184a"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM item_classes ORDER BY created_at",
"query": "SELECT id FROM items WHERE short_id = $1",
"describe": {
"columns": [
{
@ -10,11 +10,13 @@
}
],
"parameters": {
"Left": []
"Left": [
"Int4"
]
},
"nullable": [
false
]
},
"hash": "5cf503740d71431d8b9d256960c0ce194ede48a1c46326315cae9f07347597f6"
"hash": "cfce4ef19a3e0a3a0582d3c7d266b952397a017b33c351c5a33f1daa78ab908a"
}

View file

@ -1,49 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE item_classes SET name = $2, parent = $3, description = $4 WHERE id = $1 RETURNING *",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Uuid",
"Varchar"
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "e0129afa95f896d79f772fb177a8f9229dfbbfd289039db7b733c7d1d050f4bf"
}

View file

@ -1,64 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM items WHERE original_packaging = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "short_id",
"type_info": "Int4"
},
{
"ordinal": 6,
"name": "original_packaging",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "description",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
true,
false,
false,
false,
true,
false
]
},
"hash": "efe2258db42b60f732d562d106842b19308d9a558703b02e758f60e7d8644d00"
}

View file

@ -1,30 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM item_classes WHERE id = $1",
"query": "SELECT name, parent, description FROM item_classes WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"ordinal": 1,
"name": "parent",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
}
@ -35,12 +25,10 @@
]
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "308962c26250f9312287a3f2f21e5da76e4cf488eedd4704019b4f14b6fbafb2"
"hash": "f446c6ab881c166102f3b20056e0709be6fbcfc596063878c0bad9df00037dbe"
}

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"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name FROM item_classes",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "fc5e536bddb5d76021164ae4f5a6b0ad52d1ae047fd004f1359be7122facf8e7"
}

View file

@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN unnest((SELECT parents FROM item_tree WHERE id = $1))\n WITH ORDINALITY AS parents(id, n)\n ON items.id = parents.id\n JOIN item_classes\n ON items.class = item_classes.id\n ORDER BY parents.n",
"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": "ff39480d9395b357d71e9af3eb37fa308f4df6a0ca6442fa7f9bbda1e34ffbbe"
}

65
Cargo.lock generated
View file

@ -540,6 +540,46 @@ dependencies = [
"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]]
name = "colorchoice"
version = "1.0.1"
@ -1016,6 +1056,12 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
@ -1113,6 +1159,15 @@ version = "1.70.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.11"
@ -1161,10 +1216,12 @@ dependencies = [
"actix-web",
"barcoders",
"base64 0.22.1",
"clap",
"datamatrix",
"enum-iterator",
"env_logger",
"futures-util",
"itertools",
"log",
"maud",
"mime",
@ -2032,7 +2089,7 @@ checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8"
dependencies = [
"dotenvy",
"either",
"heck",
"heck 0.4.1",
"hex",
"once_cell",
"proc-macro2",
@ -2170,6 +2227,12 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"

View file

@ -15,10 +15,12 @@ actix-session = { version = "0.9.0", features = ["cookie-session"] }
actix-web = { version = "4.8.0", features = ["cookies"] }
barcoders = { version = "2.0.0", default-features = false, features = ["std"] }
base64 = "0.22.1"
clap = { version = "4.5.10", features = ["derive", "env"] }
datamatrix = "0.3.1"
enum-iterator = "2.1.0"
env_logger = "0.11.3"
futures-util = "0.3.30"
itertools = "0.13.0"
log = "0.4.21"
maud = { version = "0.26.0", features = ["actix-web"] }
mime = "0.3.17"
@ -30,7 +32,7 @@ serde_urlencoded = "0.7.1"
serde_variant = "0.1.3"
sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] }
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"] }
[profile.dev.package.sqlx-macros]

View file

@ -34,10 +34,31 @@
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs",
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1721042469,
"narHash": "sha256-6FPUl7HVtvRHCCBQne7Ylp4p+dpP3P/OYuzjztZ4s70=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "f451c19376071a90d8c58ab1a953c6e9840527fd",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"pre-commit-hooks",
"git-hooks",
"nixpkgs"
]
},
@ -57,7 +78,7 @@
},
"naersk": {
"inputs": {
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1718727675,
@ -74,6 +95,38 @@
}
},
"nixpkgs": {
"locked": {
"lastModified": 1719082008,
"narHash": "sha256-jHJSUH619zBQ6WdC21fFAlDxHErKVDJ5fpN0Hgx4sjs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9693852a2070b398ee123a329e68f0dab5526681",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1720386169,
"narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "194846768975b7ad2c4988bdb82572c00222c0d7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 0,
"narHash": "sha256-F/TKWETwB5RaR8owkPPi+SPJh83AQsm6KrQAlJ8v/uA=",
@ -85,23 +138,7 @@
"type": "indirect"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1718447546,
"narHash": "sha256-JHuXsrC9pr4kA4n7LuuPfWFJUVlDBVJ1TXDVpHEuUgM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "842253bf992c3a7157b67600c2857193f126563a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"nixpkgs_3": {
"locked": {
"lastModified": 1719075281,
"narHash": "sha256-CyyxvOwFf12I91PBWz43iGT1kjsf5oi6ax7CrvaMyAo=",
@ -117,7 +154,7 @@
"type": "github"
}
},
"nixpkgs_3": {
"nixpkgs_4": {
"locked": {
"lastModified": 1718428119,
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
@ -133,41 +170,18 @@
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1719249328,
"narHash": "sha256-Bit5QIBnDuQyF+rXz5lGbm4EyOKAAkWpgh+htXzNOs0=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "cfb96902abfdb986e68a8d09ffa5c363376c973e",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"git-hooks": "git-hooks",
"naersk": "naersk",
"nixpkgs": "nixpkgs_2",
"pre-commit-hooks": "pre-commit-hooks",
"nixpkgs": "nixpkgs_3",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_3"
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1719195554,

View file

@ -12,12 +12,10 @@
naersk.url = "github:nix-community/naersk";
pre-commit-hooks.url = "github:cachix/pre-commit-hooks.nix";
pre-commit-hooks.inputs.flake-utils.follows = "flake-utils";
pre-commit-hooks.inputs.nixpkgs.follows = "nixpkgs";
git-hooks.url = "github:cachix/git-hooks.nix";
};
outputs = { self, flake-utils, nixpkgs, rust-overlay, naersk, pre-commit-hooks }: flake-utils.lib.eachDefaultSystem (system:
outputs = { self, flake-utils, nixpkgs, rust-overlay, naersk, git-hooks }: flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
@ -35,7 +33,7 @@
in
{
checks = {
pre-commit-check = pre-commit-hooks.lib.${system}.run {
pre-commit-check = git-hooks.lib.${system}.run {
src = self;
hooks = {
cargo-check = {
@ -107,6 +105,7 @@
cargo-deny
cargo-watch
clippy
graphviz
postgresql.lib
postgresql
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,
form: web::Form<LoginForm>,
query: web::Query<LoginQuery>,
config: web::Data<crate::Config>,
) -> Result<impl Responder, error::Error> {
// Very basic authentication for now (only password, hardcoded in environment variable)
if form.password
== std::env::var("SUPERUSER_PASSWORD")
.map_err(|_| error::ErrorInternalServerError("login disabled (no password set)"))?
{
// Very basic authentication for now (only password, hardcoded in configuration)
if form.password == config.superuser_password {
Identity::login(&req.extensions(), "superuser".into())
.map_err(error::ErrorInternalServerError)?;
Ok(

View file

@ -8,12 +8,10 @@ use actix_identity::Identity;
use actix_web::{error, get, post, web, HttpRequest, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::PgPool;
use sqlx::{query_scalar, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{self, datalist, forms, helpers::PageActionGroup, TemplateConfig};
use crate::manage;
use crate::models::*;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post);
@ -130,51 +128,64 @@ async fn post(
user: Identity,
) -> actix_web::Result<impl Responder> {
let data = data.into_inner();
let new_item = NewItem {
name: data.name,
class: data.class,
parent: data.parent,
original_packaging: data.original_packaging,
description: data.description,
};
if data.quantity == 1 {
let item = manage::item::add(&pool, new_item)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(
web::Redirect::to("/item/".to_owned() + &item.id.to_string())
query_scalar!(
"INSERT INTO items (name, parent, class, original_packaging, description)
VALUES ($1, $2, $3, $4, $5)
RETURNING id",
data.name,
data.parent,
data.class,
data.original_packaging,
data.description
)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)
.map(|id| {
web::Redirect::to("/item/".to_owned() + &id.to_string())
.see_other()
.respond_to(&req)
.map_into_boxed_body(),
)
.map_into_boxed_body()
})
} else {
let items = manage::item::add_multiple(&pool, new_item, data.quantity)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
path: "/items/add",
title: Some("Added Items"),
page_title: Some(Box::new("Added Items")),
page_actions: vec![PageActionGroup::generate_labels(
&items.iter().map(|i| i.id).collect::<Vec<Uuid>>(),
)],
user: Some(user),
..Default::default()
},
html! {
ul {
@for item in items {
li {
a href={ "/item/" (item.id) } { (item.id) }
query_scalar!(
"INSERT INTO items (name, parent, class, original_packaging, description)
SELECT * FROM UNNEST($1::VARCHAR[], $2::UUID[], $3::UUID[], $4::UUID[], $5::VARCHAR[])
RETURNING id",
&vec![data.name; data.quantity] as &[Option<String>],
&vec![data.parent; data.quantity] as &[Option<Uuid>],
&vec![data.class; data.quantity],
&vec![data.original_packaging; data.quantity] as &[Option<Uuid>],
&vec![data.description; data.quantity]
)
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)
.map(|ids| {
templates::base(
TemplateConfig {
path: "/items/add",
title: Some("Added Items"),
page_title: Some(Box::new("Added Items")),
page_actions: vec![PageActionGroup::generate_labels(&ids)],
user: Some(user),
..Default::default()
},
html! {
ul {
@for id in &ids {
li {
a href={ "/item/" (id) } { (id) }
}
}
}
}
a href="/items" { "Back to all items" }
},
)
.respond_to(&req)
.map_into_boxed_body())
a href="/items" { "Back to all items" }
},
)
.respond_to(&req)
.map_into_boxed_body()
})
}
}

View file

@ -4,11 +4,9 @@
use actix_identity::Identity;
use actix_web::{error, post, web, Responder};
use sqlx::PgPool;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::manage;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(post);
}
@ -21,7 +19,8 @@ async fn post(
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
manage::item::delete(&pool, id)
query!("DELETE FROM items WHERE id = $1", id)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;

View file

@ -7,17 +7,25 @@ use std::fmt::Display;
use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder};
use maud::html;
use sqlx::PgPool;
use serde::Deserialize;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{self, datalist, forms, helpers::ItemName, TemplateConfig};
use crate::manage;
use crate::models::*;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post);
}
#[derive(Deserialize)]
struct ItemEditForm {
name: Option<String>,
parent: Option<Uuid>,
class: Uuid,
original_packaging: Option<Uuid>,
description: String,
}
#[get("/item/{id}/edit")]
async fn get(
pool: web::Data<PgPool>,
@ -26,13 +34,35 @@ async fn get(
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item = manage::item::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let item_class = manage::item_class::get(&pool, item.class)
.await
.map_err(error::ErrorInternalServerError)?;
let (item_name, form) = query!(
r#"SELECT
items.name,
items.parent,
items.class,
item_classes.name AS "class_name",
items.original_packaging,
items.description
FROM items
JOIN item_classes
ON items.class = item_classes.id
WHERE items.id = $1"#,
id
)
.map(|row| {
(
ItemName::new(row.name.as_ref(), &row.class_name),
ItemEditForm {
name: row.name,
parent: row.parent,
class: row.class,
original_packaging: row.original_packaging,
description: row.description,
},
)
})
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_items = datalist::items(&pool)
.await
@ -42,13 +72,12 @@ async fn get(
.await
.map_err(error::ErrorInternalServerError)?;
let item_name = ItemName::new(item.name.as_ref(), &item_class.name);
let mut title = item_name.to_string();
title.push_str(" Edit Item");
Ok(templates::base(
TemplateConfig {
path: &format!("/item/{}/edit", item.id),
path: &format!("/item/{}/edit", id),
title: Some(&title),
page_title: Some(Box::new(item_name.clone())),
datalists: vec![&datalist_items, &datalist_item_classes],
@ -63,7 +92,7 @@ async fn get(
title: "UUID",
required: true,
disabled: true,
value: Some(&item.id),
value: Some(&id),
..Default::default()
})
(forms::InputGroup {
@ -71,8 +100,8 @@ async fn get(
name: "name",
title: "Name",
optional: true,
disabled: item.name.is_none(),
value: item.name.as_ref().map(|s| s as &dyn Display),
disabled: form.name.is_none(),
value: form.name.as_ref().map(|s| s as &dyn Display),
..Default::default()
})
(forms::InputGroup {
@ -80,7 +109,7 @@ async fn get(
name: "class",
title: "Class",
required: true,
value: Some(&item.class),
value: Some(&form.class),
datalist: Some(&datalist_item_classes),
..Default::default()
})
@ -89,8 +118,8 @@ async fn get(
name: "parent",
title: "Parent",
optional: true,
value: item.parent.as_ref().map(|id| id as &dyn Display),
disabled: item.parent.is_none(),
value: form.parent.as_ref().map(|id| id as &dyn Display),
disabled: form.parent.is_none(),
datalist: Some(&datalist_items),
..Default::default()
})
@ -99,8 +128,8 @@ async fn get(
name: "original_packaging",
title: "Original Packaging",
optional: true,
value: item.original_packaging.as_ref().map(|id| id as &dyn Display),
disabled: item.original_packaging.is_none(),
value: form.original_packaging.as_ref().map(|id| id as &dyn Display),
disabled: form.original_packaging.is_none(),
datalist: Some(&datalist_items),
..Default::default()
})
@ -108,7 +137,7 @@ async fn get(
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: Some(&item.description),
value: Some(&form.description),
..Default::default()
})
@ -122,14 +151,25 @@ async fn get(
async fn post(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
data: web::Form<NewItem>,
data: web::Form<ItemEditForm>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item = manage::item::update(&pool, id, data.into_inner())
.await
.map_err(error::ErrorInternalServerError)?;
query!(
"UPDATE items
SET name = $2, parent = $3, class = $4, original_packaging = $5, description = $6
WHERE id = $1",
id,
data.name,
data.parent,
data.class,
data.original_packaging,
data.description
)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item/".to_owned() + &item.id.to_string()).see_other())
Ok(web::Redirect::to("/item/".to_owned() + &id.to_string()).see_other())
}

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

@ -2,57 +2,93 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use maud::html;
use sqlx::PgPool;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{
self,
helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod},
helpers::{
Colour, ItemName, ItemPreview, ItemState, PageAction, PageActionGroup, PageActionMethod,
},
TemplateConfig,
};
use crate::manage;
use crate::models::*;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
struct ItemListEntry {
id: Uuid,
name: ItemName,
class: Uuid,
class_name: String,
parents: Vec<ItemPreview>,
state: ItemState,
}
#[get("/items")]
async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> {
let item_list = manage::item::get_all(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let items = query!(
r#"
WITH RECURSIVE cte AS (
SELECT
id,
ARRAY[]::UUID[] AS parents,
ARRAY[]::VARCHAR[] AS parent_names,
ARRAY[]::VARCHAR[] AS parent_class_names
FROM items
WHERE parent IS NULL
let items = manage::item::get_all_as_map(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
UNION
let item_classes = manage::item_class::get_all_as_map(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let item_tree = manage::item::get_all_parents(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
// TODO: remove clone (should be possible without it)
let item_parents: HashMap<Uuid, Vec<Item>> = item_tree
.iter()
.map(|(id, parent_ids)| {
(
*id,
parent_ids
.iter()
.map(|parent_id| items.get(parent_id).unwrap().clone())
.collect(),
)
})
.collect();
SELECT
items.id,
cte.parents || items.parent,
cte.parent_names || parent.name,
cte.parent_class_names || parent_class.name
FROM cte
JOIN items
ON items.parent = cte.id
JOIN items AS "parent"
ON parent.id = cte.id
JOIN item_classes AS "parent_class"
ON parent.class = parent_class.id
)
SELECT
cte.id AS "id!",
items.name,
items.class,
item_classes.name AS "class_name",
cte.parents AS "parents!",
cte.parent_names AS "parent_names!: Vec<Option<String>>",
cte.parent_class_names AS "parent_class_names!",
item_states.state AS "state!: ItemState"
FROM cte
JOIN items
ON cte.id = items.id
JOIN item_classes
ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
ORDER BY items.created_at
"#
)
.map(|row| ItemListEntry {
id: row.id,
name: ItemName::new(row.name.as_ref(), &row.class_name),
class: row.class,
class_name: row.class_name,
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))
.collect(),
state: row.state,
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
@ -77,29 +113,18 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
thead {
tr {
th { "Name" }
th { "State" }
th { "Class" }
th { "Parents" }
}
}
tbody {
@for item in item_list {
@let class = item_classes.get(&item.class).unwrap();
@let parents = item_parents.get(&item.id).unwrap();
@for item in items {
tr {
td {
(ItemPreview::new(item.id, ItemName::new(item.name.as_ref(), &class.name).terse()))
}
td { a href={ "/item-class/" (class.id) } { (class.name) } }
td {
(templates::helpers::parents_breadcrumb(
ItemName::new(
item.name.as_ref(),
&class.name
),
&parents.iter().map(|parent| ItemPreview::from_parts(parent.id, parent.name.as_ref(), &item_classes.get(&parent.class).unwrap().name)).collect::<Vec<ItemPreview>>(),
false
))
}
td { (ItemPreview::new(item.id, item.name.clone().terse())) }
td { (item.state) }
td { a href={ "/item-class/" (item.class) } { (item.class_name) } }
td { (templates::helpers::parents_breadcrumb(item.name, &item.parents, false)) }
}
}
}

View file

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

View file

@ -5,20 +5,42 @@
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use maud::html;
use sqlx::PgPool;
use serde_variant::to_variant_name;
use sqlx::{query, query_as, PgPool};
use time::{Date, OffsetDateTime};
use uuid::Uuid;
use crate::frontend::templates::{
self,
helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod},
self, forms,
helpers::{
Colour, ItemEvent, ItemName, ItemPreview, ItemState, PageAction, PageActionGroup,
PageActionMethod,
},
TemplateConfig,
};
use crate::manage;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
struct ItemDetails {
id: Uuid,
short_id: i32,
name: ItemName,
class: Uuid,
class_name: String,
original_packaging: Option<ItemPreview>,
description: String,
state: ItemState,
}
struct ItemEventDetails {
id: i32,
date: Date,
event: ItemEvent,
description: String,
}
#[get("/item/{id}")]
async fn get(
pool: web::Data<PgPool>,
@ -27,46 +49,143 @@ async fn get(
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item = manage::item::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let item = query!(
r#"SELECT
items.id,
items.short_id,
items.name,
items.class,
item_classes.name AS "class_name",
items.original_packaging,
op.name AS "original_packaging_name?",
op_class.name AS "original_packaging_class_name?",
op_state.state AS "original_packaging_state: ItemState",
items.description,
item_states.state AS "state!: ItemState"
FROM items
JOIN item_classes
ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
LEFT JOIN items AS "op"
ON items.original_packaging = op.id
LEFT JOIN item_classes AS "op_class"
ON op.class = op_class.id
LEFT JOIN item_states AS "op_state"
ON op.id = op_state.item
WHERE items.id = $1"#,
id
)
.map(|row| ItemDetails {
id: row.id,
short_id: row.short_id,
name: ItemName::new(row.name.as_ref(), &row.class_name),
class: row.class,
class_name: row.class_name,
original_packaging: row.original_packaging.map(|id| {
ItemPreview::from_parts(
id,
row.original_packaging_name.as_ref(),
&row.original_packaging_class_name.unwrap(),
)
.with_state(row.original_packaging_state.unwrap())
}),
description: row.description,
state: row.state,
})
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let item_classes = manage::item_class::get_all_as_map(&pool)
.await
.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 parents = manage::item::get_parents_details(&pool, item.id)
.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 children = manage::item::get_children(&pool, item.id)
.await
.map_err(error::ErrorInternalServerError)?;
let parents = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name"
FROM items
JOIN unnest((SELECT parents FROM item_tree WHERE id = $1))
WITH ORDINALITY AS parents(id, n)
ON items.id = parents.id
JOIN item_classes
ON items.class = item_classes.id
ORDER BY parents.n"#,
id
)
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name))
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let original_packaging = match item.original_packaging {
Some(id) => Some(
manage::item::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?,
),
None => None,
};
let children = query!(
r#"SELECT
items.id,
items.name,
item_classes.name AS "class_name",
item_states.state AS "state!: ItemState"
FROM items
JOIN item_classes
ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
WHERE items.parent = $1"#,
id
)
.map(|row| {
ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state)
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let original_packaging_of = manage::item::original_packaging_contents(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let original_packaging_of = query!(
r#"SELECT
items.id,
items.name,
item_classes.name AS "class_name",
item_states.state AS "state!: ItemState"
FROM items
JOIN item_classes
ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
WHERE items.original_packaging = $1"#,
id
)
.map(|row| {
ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state)
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let item_class = item_classes.get(&item.class).unwrap();
let item_name = ItemName::new(item.name.as_ref(), &item_class.name);
let mut title = item_name.to_string();
let mut title = item.name.to_string();
title.push_str(" Item Details");
Ok(templates::base(
TemplateConfig {
path: &format!("/item/{}", item.id),
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![
(PageActionGroup::Button {
action: PageAction {
@ -98,51 +217,122 @@ async fn get(
..Default::default()
},
html! {
table .table {
tr {
th { "UUID" }
td { (item.id) }
}
tr {
th { "Short ID" }
td { (item.short_id) }
}
tr {
th { "Name" }
td { (item_name.clone().terse()) }
}
tr {
th { "Class" }
td { a href={ "/item-class/" (item.class) } { (item_class.name) } }
}
tr {
th { "Parents" }
td {
(templates::helpers::parents_breadcrumb(
ItemName::new(
item.name.as_ref(),
&item_class.name
),
&parents.iter().map(|parent| ItemPreview::from_parts(parent.id, parent.name.as_ref(), &item_classes.get(&parent.class).unwrap().name)).collect::<Vec<ItemPreview>>(),
true
))
}
}
tr {
th { "Original Packaging" }
td {
@if let Some(original_packaging) = original_packaging {
a
href={ "/item/" (original_packaging.id) }
{ (ItemName::new(original_packaging.name.as_ref(), &item_classes.get(&original_packaging.class).unwrap().name)) }
} @else {
"-"
div .row {
div .col-md-8 {
table .table {
tr {
th { "UUID" }
td { (item.id) }
}
tr {
th { "Short ID" }
td { (item.short_id) }
}
tr {
th { "Name" }
td { (item.name.clone().terse()) }
}
tr {
th { "Class" }
td { a href={ "/item-class/" (item.class) } { (item.class_name) } }
}
tr {
th { "Parents" }
td { (templates::helpers::parents_breadcrumb(item.name, &parents, true)) }
}
tr {
th { "Original Packaging" }
td {
@if let Some(original_packaging) = item.original_packaging {
(original_packaging)
} @else {
"-"
}
}
}
tr {
th { "Description" }
td style="white-space: pre-wrap" { (item.description) }
}
}
}
tr {
th { "Description" }
td style="white-space: pre-wrap" { (item.description) }
div .col-md-4 {
div .card {
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" }
}
}
}
}
}
@ -153,16 +343,14 @@ async fn get(
}
div {
(PageActionGroup::generate_labels(
&children.iter().map(|i| i.id).collect::<Vec<Uuid>>(),
&children.iter().map(|ip| ip.id).collect::<Vec<Uuid>>(),
))
}
}
ul {
@for child in children {
li {
(ItemPreview::from_parts(child.id, child.name.as_ref(), &item_classes.get(&child.class).unwrap().name))
}
li { (child) }
}
}
}
@ -172,9 +360,7 @@ async fn get(
ul {
@for item in original_packaging_of {
li {
(ItemPreview::from_parts(item.id, item.name.as_ref(), &item_classes.get(&item.class).unwrap().name))
}
li { (item) }
}
}
}

View file

@ -8,12 +8,10 @@ use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::PgPool;
use sqlx::{query_scalar, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{self, datalist, forms, TemplateConfig};
use crate::manage;
use crate::models::*;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post);
@ -93,15 +91,18 @@ async fn post(
_user: Identity,
) -> actix_web::Result<impl Responder> {
let data = data.into_inner();
let item = manage::item_class::add(
&pool,
NewItemClass {
name: data.name,
parent: data.parent,
description: data.description,
},
let id = query_scalar!(
"INSERT INTO item_classes (name, parent, description)
VALUES ($1, $2, $3)
RETURNING id",
data.name,
data.parent,
data.description
)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-class/".to_owned() + &item.id.to_string()).see_other())
Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other())
}

View file

@ -4,11 +4,9 @@
use actix_identity::Identity;
use actix_web::{error, post, web, Responder};
use sqlx::PgPool;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::manage;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(post);
}
@ -21,7 +19,8 @@ async fn post(
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
manage::item_class::delete(&pool, id)
query!("DELETE FROM item_classes WHERE id = $1", id)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;

View file

@ -7,17 +7,23 @@ use std::fmt::Display;
use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder};
use maud::html;
use sqlx::PgPool;
use serde::Deserialize;
use sqlx::{query, query_as, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{self, datalist, forms, TemplateConfig};
use crate::manage;
use crate::models::*;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post);
}
#[derive(Deserialize)]
struct ItemClassEditForm {
name: String,
parent: Option<Uuid>,
description: String,
}
#[get("/item-class/{id}/edit")]
async fn get(
pool: web::Data<PgPool>,
@ -26,22 +32,27 @@ async fn get(
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_class = manage::item_class::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let form = query_as!(
ItemClassEditForm,
"SELECT name, parent, description FROM item_classes WHERE id = $1",
id
)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_item_classes = datalist::item_classes(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = item_class.name.clone();
let mut title = form.name.clone();
title.push_str(" Item Details");
Ok(templates::base(
TemplateConfig {
path: &format!("/items-class/{}/add", id),
title: Some(&title),
page_title: Some(Box::new(item_class.name.clone())),
page_title: Some(Box::new(form.name.clone())),
datalists: vec![&datalist_item_classes],
user: Some(user),
..Default::default()
@ -54,7 +65,7 @@ async fn get(
title: "UUID",
disabled: true,
required: true,
value: Some(&item_class.id),
value: Some(&id),
..Default::default()
})
(forms::InputGroup {
@ -62,7 +73,7 @@ async fn get(
name: "name",
title: "Name",
required: true,
value: Some(&item_class.name),
value: Some(&form.name),
..Default::default()
})
(forms::InputGroup {
@ -70,8 +81,8 @@ async fn get(
name: "parent",
title: "Parent",
optional: true,
disabled: item_class.parent.is_none(),
value: item_class.parent.as_ref().map(|id| id as &dyn Display),
disabled: form.parent.is_none(),
value: form.parent.as_ref().map(|id| id as &dyn Display),
datalist: Some(&datalist_item_classes),
..Default::default()
})
@ -79,7 +90,7 @@ async fn get(
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: Some(&item_class.description),
value: Some(&form.description),
..Default::default()
})
@ -93,14 +104,23 @@ async fn get(
async fn post(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
data: web::Form<NewItemClass>,
data: web::Form<ItemClassEditForm>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_class = manage::item_class::update(&pool, id, data.into_inner())
.await
.map_err(error::ErrorInternalServerError)?;
query!(
"UPDATE item_classes
SET name = $2, parent = $3, description = $4
WHERE id = $1",
id,
data.name,
data.parent,
data.description
)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-class/".to_owned() + &item_class.id.to_string()).see_other())
Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other())
}

View file

@ -5,29 +5,45 @@
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use maud::html;
use sqlx::PgPool;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{
self,
helpers::{Colour, PageAction, PageActionGroup, PageActionMethod},
helpers::{Colour, ItemClassPreview, PageAction, PageActionGroup, PageActionMethod},
TemplateConfig,
};
use crate::manage;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
struct ItemClassListEntry {
id: Uuid,
name: String,
parent: Option<ItemClassPreview>,
}
#[get("/item-classes")]
async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> {
let item_classes_ids = sqlx::query_scalar!("SELECT id FROM item_classes ORDER BY created_at")
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let item_classes = manage::item_class::get_all_as_map(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let item_classes = query!(
r#"SELECT class.id, class.name, class.parent, parent.name AS "parent_name?"
FROM item_classes AS "class"
LEFT JOIN item_classes AS "parent"
ON class.parent = parent.id
ORDER BY class.created_at
"#
)
.map(|row| ItemClassListEntry {
id: row.id,
name: row.name,
parent: row
.parent
.map(|id| ItemClassPreview::new(id, row.parent_name.unwrap())),
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
@ -56,14 +72,12 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
}
}
tbody {
@for item_class in item_classes_ids {
@let item_class = item_classes.get(&item_class).unwrap();
@for item_class in item_classes {
tr {
td { a href={ "/item-class/" (item_class.id) } { (item_class.name) } }
td { (ItemClassPreview::new(item_class.id, item_class.name)) }
td {
@if let Some(parent) = item_class.parent {
@let parent = item_classes.get(&parent).unwrap();
a href={ "/item-class/" (parent.id) } { (parent.name) }
(parent)
} @else {
"-"
}

View file

@ -5,20 +5,29 @@
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use maud::html;
use sqlx::PgPool;
use sqlx::{query, query_as, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{
self,
helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod},
helpers::{
Colour, ItemClassPreview, ItemPreview, ItemState, PageAction, PageActionGroup,
PageActionMethod,
},
TemplateConfig,
};
use crate::manage;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
struct ItemClassDetails {
id: Uuid,
name: String,
description: String,
parent: Option<ItemClassPreview>,
}
#[get("/item-class/{id}")]
async fn get(
pool: web::Data<PgPool>,
@ -27,26 +36,60 @@ async fn get(
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_class = manage::item_class::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let item_class = query!(
r#"SELECT
class.id,
class.name,
class.description,
class.parent,
parent.name AS "parent_name?"
FROM item_classes AS "class"
LEFT JOIN item_classes AS "parent"
ON class.parent = parent.id
WHERE class.id = $1"#,
id
)
.map(|row| ItemClassDetails {
id: row.id,
name: row.name,
description: row.description,
parent: row
.parent
.map(|id| ItemClassPreview::new(id, row.parent_name.unwrap())),
})
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
// TODO: Once async closures are stable, use map_or on item_class.parent instead
let parent = match item_class.parent {
Some(id) => manage::item_class::get(&pool, id)
.await
.map(Some)
.map_err(error::ErrorInternalServerError)?,
None => None,
};
let children = query_as!(
ItemClassPreview,
"SELECT id, name FROM item_classes WHERE parent = $1",
id
)
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let children = manage::item_class::children(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let items = manage::item_class::items(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let items = query!(
r#"SELECT
items.id,
items.name,
item_classes.name AS "class_name",
item_states.state AS "state!: ItemState"
FROM items
JOIN item_classes
ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
WHERE items.class = $1"#,
id
)
.map(|row| {
ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state)
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = item_class.name.clone();
title.push_str(" Item Details");
@ -107,10 +150,10 @@ async fn get(
th { "Name" }
td { (item_class.name) }
}
@if let Some(parent) = parent {
@if let Some(parent) = item_class.parent {
tr {
th { "Parent" }
td { a href={ "/item-class/" (parent.id) } { (parent.name) } }
td { (parent) }
}
}
tr {
@ -124,9 +167,7 @@ async fn get(
ul {
@for child in children {
li {
a href={ "/item-class/" (child.id) } { (child.name) }
}
li { (child) }
}
}
}
@ -145,9 +186,7 @@ async fn get(
ul {
@for item in items {
li {
(ItemPreview::new(item.id, ItemName::new(item.name.as_ref(), &item_class.name).terse()))
}
li { (item) }
}
}
}

View file

@ -5,16 +5,19 @@
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use serde::Deserialize;
use sqlx::PgPool;
use sqlx::{query, query_scalar, PgPool};
use uuid::Uuid;
use crate::manage;
use crate::models::EntityType;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
#[derive(Deserialize)]
pub enum EntityType {
Item,
ItemClass,
}
#[derive(Deserialize)]
struct JumpData {
id: String,
@ -29,15 +32,29 @@ async fn get(
let mut id = data.id.clone();
let entity_type = if let Ok(id) = Uuid::parse_str(&id) {
manage::query_entity_type(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?
query!(
r#"SELECT type as "type!"
FROM (SELECT id, 'item' AS "type" FROM items
UNION ALL
SELECT id, 'item_class' AS "type" FROM item_classes) id_mapping
WHERE id = $1"#,
id
)
.map(|row| match row.r#type.as_str() {
"item" => EntityType::Item,
"item_class" => EntityType::ItemClass,
_ => unreachable!("database returned impossible type"),
})
.fetch_optional(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?
} else if let Ok(short_id) = id.parse::<i32>() {
if let Ok(item) = manage::item::get_by_short_id(&pool, short_id)
if let Ok(id_) = query_scalar!("SELECT id FROM items WHERE short_id = $1", short_id)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)
{
id = item.id.to_string();
id = id_.to_string();
Some(EntityType::Item)
} else {
None

View file

@ -7,12 +7,12 @@ use actix_web::{error, get, post, web, Responder};
use maud::html;
use serde::Deserialize;
use serde_variant::to_variant_name;
use sqlx::PgPool;
use sqlx::{query, PgPool};
use uuid::Uuid;
use super::templates::{self, datalist, helpers::ItemName, TemplateConfig};
use super::templates::{self, datalist, TemplateConfig};
use crate::frontend::templates::helpers::ItemPreview;
use crate::label::{Label, LabelPreset};
use crate::manage;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(generate_post)
@ -35,11 +35,9 @@ async fn generate(pool: &PgPool, params: GenerateParams) -> actix_web::Result<im
.collect::<Result<Vec<Uuid>, uuid::Error>>()
.map_err(error::ErrorInternalServerError)?;
let items = manage::item::get_multiple(pool, &ids)
Label::for_items(pool, &ids, params.preset.clone().into())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(Label::for_items(&items, params.preset.clone().into()))
.map_err(error::ErrorInternalServerError)
}
#[post("/labels/generate")]
@ -62,13 +60,16 @@ async fn generate_get(
#[get("/labels")]
async fn form(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> {
let items = manage::item::get_all(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let item_classes = manage::item_class::get_all_as_map(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let items = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name"
FROM items
JOIN item_classes
ON items.class = item_classes.id"#
)
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name))
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_items = datalist::items(&pool)
.await
@ -120,8 +121,7 @@ async fn form(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
label .form-label for="ids-multiselect" { "Items" }
select #ids-multiselect .form-select multiple size="15" {
@for item in items {
@let item_name = ItemName::new(item.name.as_ref(), &item_classes.get(&item.class).unwrap().name);
option value={ (item.id) } { (item_name.to_string()) " (" (item.id) ")" }
option value={ (item.id) } { (item.name) " (" (item.id) ")" }
}
}
}

View file

@ -3,10 +3,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{html, Markup, Render};
use sqlx::PgPool;
use sqlx::{query, PgPool};
use super::helpers::ItemName;
use crate::manage;
pub struct Datalist {
name: String,
@ -44,23 +43,23 @@ impl Render for DatalistOption {
}
pub async fn items(pool: &PgPool) -> Result<Datalist, sqlx::Error> {
let items = manage::item::get_all(pool).await?;
let item_classes = manage::item_class::get_all_as_map(pool).await?;
Ok(Datalist {
name: "items".to_string(),
link_prefix: Some("/item/".to_string()),
options: items
.iter()
.map(|i| DatalistOption {
value: i.id.to_string(),
text: Box::new(ItemName::new(
i.name.as_ref(),
&item_classes.get(&i.class).unwrap().name,
)),
})
.collect(),
options: query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name"
FROM items
JOIN item_classes
ON items.class = item_classes.id"#
)
.fetch_all(pool)
.await?
.into_iter()
.map(|row| DatalistOption {
value: row.id.to_string(),
text: Box::new(ItemName::new(row.name.as_ref(), &row.class_name)),
})
.collect(),
})
}
@ -68,12 +67,13 @@ pub async fn item_classes(pool: &PgPool) -> Result<Datalist, sqlx::Error> {
Ok(Datalist {
name: "item-classes".to_string(),
link_prefix: Some("/item-class/".to_string()),
options: manage::item_class::get_all(pool)
options: query!("SELECT id, name FROM item_classes")
.fetch_all(pool)
.await?
.into_iter()
.map(|ic| DatalistOption {
value: ic.id.to_string(),
text: Box::new(ic.name),
.map(|row| DatalistOption {
value: row.id.to_string(),
text: Box::new(row.name),
})
.collect(),
})

View file

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

View file

@ -6,6 +6,7 @@ use std::fmt::{self, Display};
use crate::label::LabelPreset;
use maud::{html, Markup, PreEscaped, Render};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub enum Css<'a> {
@ -78,9 +79,13 @@ impl fmt::Display for Colour {
}
impl Colour {
fn button(&self) -> String {
pub fn button(&self) -> String {
format!("btn-{self}")
}
pub fn text_bg(&self) -> String {
format!("text-bg-{self}")
}
}
#[derive(Clone)]
@ -134,25 +139,58 @@ impl Render for ItemName {
pub struct ItemPreview {
pub id: Uuid,
pub name: ItemName,
pub state: Option<ItemState>,
}
impl ItemPreview {
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 {
Self {
id,
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 {
fn render(&self) -> Markup {
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)
}
}
}
}
pub struct ItemClassPreview {
pub id: Uuid,
pub name: String,
}
impl ItemClassPreview {
pub fn new(id: Uuid, name: String) -> Self {
Self { id, name }
}
}
impl Render for ItemClassPreview {
fn render(&self) -> Markup {
html! {
a href={ "/item-class/" (self.id) } { (self.name) }
}
}
}
@ -260,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

@ -15,7 +15,6 @@ use helpers::*;
const BRANDING: &str = "li7y";
const NAVBAR_ITEMS: &[(&str, &str)] = &[
("/", "Home"),
("/items", "Items"),
("/item-classes", "Item Classes"),
("/labels", "Labels"),
@ -69,6 +68,7 @@ pub struct TemplateConfig<'a> {
pub path: &'a str,
pub title: Option<&'a str>,
pub page_title: Option<Box<dyn Render + 'a>>,
pub page_title_extra: Option<Box<dyn Render + 'a>>,
pub page_actions: Vec<PageActionGroup>,
pub extra_css: Vec<Css<'a>>,
pub extra_js: Vec<Js<'a>>,
@ -82,6 +82,7 @@ impl Default for TemplateConfig<'_> {
path: "/",
title: None,
page_title: None,
page_title_extra: None,
page_actions: Vec::new(),
extra_css: Vec::new(),
extra_js: Vec::new(),
@ -116,19 +117,24 @@ pub fn base(config: TemplateConfig, content: Markup) -> Markup {
(navbar(&config))
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 {
h2 {
(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)

View file

@ -12,10 +12,10 @@ use barcode::{encode_code128, encode_data_matrix};
use pdf::{IndirectFontRef, PdfLayerReference};
use printpdf as pdf;
use printpdf::{ImageTransform, Mm, PdfDocument, PdfDocumentReference, Pt, Px};
use sqlx::{query_as, PgPool};
use thiserror::Error;
use uuid::Uuid;
use crate::models::Item;
pub use preset::LabelPreset;
const FONT: Cursor<&[u8]> = Cursor::new(include_bytes!(
@ -247,16 +247,18 @@ impl Label {
Ok(doc.ok_or(Error::NoPages)?.save_to_bytes()?)
}
pub fn for_items(items: &[Item], config: LabelConfig) -> Self {
Label {
pages: items
.iter()
.map(|item| LabelPage {
id: Some(item.id),
short_id: Some(format!("{:06}", item.short_id)),
})
.collect(),
pub async fn for_items(pool: &PgPool, ids: &[Uuid], config: LabelConfig) -> sqlx::Result<Self> {
Ok(Label {
pages: query_as!(
LabelPage,
r#"SELECT id AS "id?", to_char(short_id, '000000') AS "short_id?"
FROM items
WHERE id = ANY ($1)"#,
ids
)
.fetch_all(pool)
.await?,
config,
}
})
}
}

View file

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

View file

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

View file

@ -1,155 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use sqlx::{query, query_as, query_scalar, PgPool};
use uuid::Uuid;
use crate::models::{Item, NewItem};
pub async fn add(pool: &PgPool, new_item: NewItem) -> Result<Item, sqlx::Error> {
query_as!(
Item,
"INSERT INTO items (name, parent, class, original_packaging, description) VALUES ($1, $2, $3, $4, $5) RETURNING *",
new_item.name,
new_item.parent,
new_item.class,
new_item.original_packaging,
new_item.description
)
.fetch_one(pool)
.await
}
pub async fn add_multiple(
pool: &PgPool,
new_item: NewItem,
quantity: usize,
) -> Result<Vec<Item>, sqlx::Error> {
query_as!(
Item,
r#"INSERT INTO items (name, parent, class, original_packaging, description)
SELECT * FROM UNNEST($1::VARCHAR[], $2::UUID[], $3::UUID[], $4::UUID[], $5::VARCHAR[])
RETURNING *"#,
&vec![new_item.name; quantity] as &[Option<String>],
&vec![new_item.parent; quantity] as &[Option<Uuid>],
&vec![new_item.class; quantity],
&vec![new_item.original_packaging; quantity] as &[Option<Uuid>],
&vec![new_item.description; quantity]
)
.fetch_all(pool)
.await
}
pub async fn get(pool: &PgPool, id: Uuid) -> Result<Item, sqlx::Error> {
query_as!(Item, "SELECT * FROM items WHERE id = $1", id)
.fetch_one(pool)
.await
}
pub async fn get_by_short_id(pool: &PgPool, short_id: i32) -> Result<Item, sqlx::Error> {
query_as!(Item, "SELECT * FROM items WHERE short_id = $1", short_id)
.fetch_one(pool)
.await
}
pub async fn get_all(pool: &PgPool) -> Result<Vec<Item>, sqlx::Error> {
query_as!(Item, "SELECT * FROM items ORDER BY created_at")
.fetch_all(pool)
.await
}
pub async fn get_multiple(pool: &PgPool, ids: &[Uuid]) -> Result<Vec<Item>, sqlx::Error> {
query_as!(Item, "SELECT * FROM items WHERE id = ANY ($1)", ids)
.fetch_all(pool)
.await
}
pub async fn get_all_as_map(pool: &PgPool) -> Result<HashMap<Uuid, Item>, sqlx::Error> {
Ok(get_all(pool)
.await?
.into_iter()
.map(|i| (i.id, i))
.collect())
}
pub async fn update(pool: &PgPool, id: Uuid, modified_item: NewItem) -> Result<Item, sqlx::Error> {
query_as!(
Item,
"UPDATE items SET name = $2, parent = $3, class = $4, original_packaging = $5, description = $6 WHERE id = $1 RETURNING *",
id,
modified_item.name,
modified_item.parent,
modified_item.class,
modified_item.original_packaging,
modified_item.description
)
.fetch_one(pool)
.await
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
let res = query!("DELETE FROM items WHERE id = $1", id)
.execute(pool)
.await?;
assert_eq!(res.rows_affected(), 1);
Ok(())
}
pub async fn get_parents(pool: &PgPool, id: Uuid) -> Result<Vec<Uuid>, sqlx::Error> {
// force nullable is required for all columns in views
query_scalar!(
r#"SELECT unnest(parents) as "parents!" FROM item_tree WHERE id = $1"#,
id
)
.fetch_all(pool)
.await
}
pub async fn get_all_parents(pool: &PgPool) -> Result<HashMap<Uuid, Vec<Uuid>>, sqlx::Error> {
let mut parents = HashMap::new();
for row in query!(r#"SELECT id as "id!", parents as "parents!" FROM item_tree"#)
.fetch_all(pool)
.await?
{
parents.insert(row.id, row.parents);
}
Ok(parents)
}
pub async fn get_parents_details(pool: &PgPool, id: Uuid) -> Result<Vec<Item>, sqlx::Error> {
query_as!(
Item,
"SELECT items.*
FROM items
INNER JOIN
unnest((SELECT parents FROM item_tree WHERE id = $1))
WITH ORDINALITY AS parents(id, n)
ON items.id = parents.id
ORDER BY parents.n;",
id
)
.fetch_all(pool)
.await
}
pub async fn get_children(pool: &PgPool, id: Uuid) -> Result<Vec<Item>, sqlx::Error> {
query_as!(Item, "SELECT * FROM items WHERE parent = $1", id)
.fetch_all(pool)
.await
}
pub async fn original_packaging_contents(
pool: &PgPool,
id: Uuid,
) -> Result<Vec<Item>, sqlx::Error> {
query_as!(
Item,
"SELECT * FROM items WHERE original_packaging = $1",
id
)
.fetch_all(pool)
.await
}

View file

@ -1,83 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use sqlx::{query, query_as, PgPool};
use uuid::Uuid;
use crate::models::{Item, ItemClass, NewItemClass};
pub async fn add(pool: &PgPool, new_item_class: NewItemClass) -> Result<ItemClass, sqlx::Error> {
query_as!(
ItemClass,
"INSERT INTO item_classes (name, parent, description) VALUES ($1, $2, $3) RETURNING *",
new_item_class.name,
new_item_class.parent,
new_item_class.description
)
.fetch_one(pool)
.await
}
pub async fn get(pool: &PgPool, id: Uuid) -> Result<ItemClass, sqlx::Error> {
query_as!(ItemClass, "SELECT * FROM item_classes WHERE id = $1", id)
.fetch_one(pool)
.await
}
pub async fn get_all(pool: &PgPool) -> Result<Vec<ItemClass>, sqlx::Error> {
query_as!(ItemClass, "SELECT * FROM item_classes ORDER BY created_at")
.fetch_all(pool)
.await
}
pub async fn get_all_as_map(pool: &PgPool) -> Result<HashMap<Uuid, ItemClass>, sqlx::Error> {
Ok(get_all(pool)
.await?
.into_iter()
.map(|ic| (ic.id, ic))
.collect())
}
pub async fn update(
pool: &PgPool,
id: Uuid,
modified_item_class: NewItemClass,
) -> Result<ItemClass, sqlx::Error> {
query_as!(
ItemClass,
"UPDATE item_classes SET name = $2, parent = $3, description = $4 WHERE id = $1 RETURNING *",
id,
modified_item_class.name,
modified_item_class.parent,
modified_item_class.description
)
.fetch_one(pool)
.await
}
pub async fn items(pool: &PgPool, id: Uuid) -> Result<Vec<Item>, sqlx::Error> {
query_as!(Item, "SELECT * FROM items WHERE class = $1", id)
.fetch_all(pool)
.await
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
let res = query!("DELETE FROM item_classes WHERE id = $1", id)
.execute(pool)
.await?;
assert_eq!(res.rows_affected(), 1);
Ok(())
}
pub async fn children(pool: &PgPool, id: Uuid) -> Result<Vec<ItemClass>, sqlx::Error> {
query_as!(
ItemClass,
"SELECT * FROM item_classes WHERE parent = $1",
id
)
.fetch_all(pool)
.await
}

View file

@ -1,29 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod item;
pub mod item_class;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::models::EntityType;
pub async fn query_entity_type(pool: &PgPool, id: Uuid) -> Result<Option<EntityType>, sqlx::Error> {
Ok(query!(
r#"SELECT type as "type!" FROM
(SELECT id, 'item' AS "type" FROM items
UNION ALL
SELECT id, 'item_class' AS "type" FROM item_classes) id_mapping
WHERE id = $1"#,
id
)
.fetch_optional(pool)
.await?
.map(|row| match row.r#type.as_str() {
"item" => EntityType::Item,
"item_class" => EntityType::ItemClass,
_ => unreachable!("database returned impossible type"),
}))
}

View file

@ -1,56 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Deserialize)]
pub enum EntityType {
Item,
ItemClass,
}
#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
pub struct Item {
pub id: Uuid,
pub name: Option<String>,
pub parent: Option<Uuid>,
pub class: Uuid,
#[serde(with = "time::serde::iso8601")]
pub created_at: OffsetDateTime,
pub short_id: i32,
pub original_packaging: Option<Uuid>,
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct NewItem {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub parent: Option<Uuid>,
pub class: Uuid,
pub original_packaging: Option<Uuid>,
pub description: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct ItemClass {
pub id: Uuid,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<Uuid>,
#[serde(with = "time::serde::iso8601")]
pub created_at: OffsetDateTime,
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct NewItemClass {
pub name: String,
#[serde(default)]
pub parent: Option<Uuid>,
pub description: String,
}

View file

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