Compare commits
22 commits
item-state
...
master
Author | SHA1 | Date | |
---|---|---|---|
Simon Bruder | b9022ed125 | ||
Simon Bruder | dfa233dd3c | ||
Simon Bruder | 659a6cb776 | ||
Simon Bruder | bbca62fa8b | ||
Simon Bruder | 89ce5b7d40 | ||
Simon Bruder | 6007e4b96a | ||
Simon Bruder | 61f356269c | ||
Simon Bruder | 413a02cdaa | ||
Simon Bruder | ae4e583c2d | ||
Simon Bruder | cdc73d1ac5 | ||
Simon Bruder | bd1e7ad407 | ||
Simon Bruder | b22588cd0d | ||
Simon Bruder | 79c4ab6c2b | ||
Simon Bruder | 2e39ef952b | ||
Simon Bruder | 6e4e8d3d93 | ||
Simon Bruder | 716ac1a698 | ||
Simon Bruder | 61a49afaf7 | ||
Simon Bruder | c4107266c4 | ||
Simon Bruder | f33f3c8b9c | ||
Simon Bruder | a320f3834a | ||
Simon Bruder | 1e5f7930ab | ||
Simon Bruder | bcbb7dfc67 |
|
@ -20,7 +20,7 @@ jobs:
|
|||
git config --unset "http.${GITHUB_SERVER_URL}/.extraHeader"
|
||||
git lfs install --local
|
||||
git lfs pull
|
||||
- name: Build
|
||||
- name: Build and test
|
||||
run: nix build -L .#li7y .#li7y-oci
|
||||
- name: Push OCI image
|
||||
if: github.ref == 'refs/heads/master'
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,5 +3,6 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
/target
|
||||
/.tarpaulin_target
|
||||
result*
|
||||
.pre-commit-config.yaml
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n WHERE items.original_packaging = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "class_name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "02438c29c249ce2b446d15a41d07c40ce73946e62744a486cbbac256c4a4cf55"
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n items.name,\n item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n WHERE items.id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "class_name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "05d5b6e3618b5bb676d0a262a531efedec34bf08f99dda0397df4ca873859be6"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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",
|
||||
"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": [
|
||||
{
|
||||
|
@ -22,5 +22,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "857bbaea0fa73c37679d927aa627ee841c5b89c86af12f32493ba05c0f0ead91"
|
||||
"hash": "087164492bb5f51006e5091f9382b5e27a5a90908d65b7a6f6d6587f8a3e2a10"
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM items WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "08cce122c8eb9b390e5411cc08d2e735de7230a596fa2f2ba472bed6a9b4a75c"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH RECURSIVE cte AS (\n SELECT\n id,\n ARRAY[]::UUID[] AS parents,\n ARRAY[]::VARCHAR[] AS parent_names,\n ARRAY[]::VARCHAR[] AS parent_class_names\n FROM items\n WHERE parent IS NULL\n\n UNION\n\n SELECT\n items.id,\n cte.parents || items.parent,\n cte.parent_names || parent.name,\n cte.parent_class_names || parent_class.name\n FROM cte\n JOIN items\n ON items.parent = cte.id\n JOIN items AS \"parent\"\n ON parent.id = cte.id\n JOIN item_classes AS \"parent_class\"\n ON parent.class = parent_class.id\n )\n SELECT\n cte.id AS \"id!\",\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n cte.parents AS \"parents!\",\n cte.parent_names AS \"parent_names!: Vec<Option<String>>\",\n cte.parent_class_names AS \"parent_class_names!\"\n FROM cte\n JOIN items\n ON cte.id = items.id\n JOIN item_classes\n ON items.class = item_classes.id\n ORDER BY items.created_at\n ",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3dedb7b184103c1d418f7b94e26c75aea0c7d22e009299d1b87443e350578171"
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE item_class_children AS (\n SELECT\n item_classes.id,\n array_remove(array_agg(children.id), NULL) AS \"children\"\n FROM item_classes\n LEFT JOIN item_classes AS \"children\"\n ON item_classes.id = children.parent\n GROUP BY item_classes.id\n ),\n cte AS (\n SELECT\n item_classes.id,\n item_classes.name,\n item_class_children.children,\n 0 AS \"reverse_level\"\n FROM item_classes\n JOIN item_class_children\n ON item_classes.id = item_class_children.id\n WHERE item_class_children.children = '{}'\n\n UNION\n\n SELECT\n item_classes.id,\n item_classes.name,\n item_class_children.children,\n cte.reverse_level + 1\n FROM item_classes\n JOIN item_class_children\n ON item_classes.id = item_class_children.id\n JOIN cte\n ON cte.id = ANY (item_class_children.children)\n )\n SELECT\n id AS \"id!\",\n name AS \"name!\",\n children AS \"children!\"\n FROM cte\n GROUP BY id, name, children\n ORDER BY max(reverse_level)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "children!",
|
||||
"type_info": "UuidArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "689a5177bdbc4ac788579a47fe033eb6b9639a356fe7ec543691e385ffef51e7"
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM item_classes WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7a811ef54e617ed88ef9c7be88b48ca0da2204c64200ffa77a51f0c580f9ab87"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"query": "SELECT\n items.name,\n items.parent,\n items.class,\n items.original_packaging,\n items.description\n FROM items\n WHERE items.id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
@ -20,16 +20,11 @@
|
|||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "class_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "original_packaging",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 4,
|
||||
"name": "description",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
|
@ -43,10 +38,9 @@
|
|||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c49b88eda9a62743783bc894f01bb6198594f94a3e0856abde0efdb4e49dbab8"
|
||||
"hash": "7b1265f893e68df4ebde549fed8dc1872c723d1294521a72d9d2251f4ddd8da6"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n WHERE items.class = $1",
|
||||
"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": [
|
||||
{
|
||||
|
@ -20,9 +20,7 @@
|
|||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
|
@ -30,5 +28,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "482df01f0509cc8ec18ffe1caea8f65f11c170b67424e35697540ae12e577227"
|
||||
"hash": "84db69654e377998324906a80bb939d83454bb504082cb2efa723b80fde7b159"
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT item_classes.id, item_classes.name\n FROM item_classes\n JOIN unnest((SELECT parents FROM item_class_tree WHERE id = $1))\n WITH ORDINALITY AS parents(id, n)\n ON item_classes.id = parents.id\n ORDER BY parents.n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "85e1b1bae08c7edda9671214e6eeece6556236c25253a45c8ddc834751f72694"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM item_classes WHERE id = $1 RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "87067a7bda5e82c793d4e03cf041b4554a536b93a24db7401f5a88a0cf60ef4d"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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 ",
|
||||
"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": [
|
||||
{
|
||||
|
@ -34,5 +34,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "c7e35dee5f56da8da3c083e191f396b95917b15768ebe0250ce7840391036616"
|
||||
"hash": "8d9766469e77b291eceffc2ba17ff78c11283a29411233597bd27a52a934a595"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n items.id,\n items.short_id,\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n items.original_packaging,\n op.name AS \"original_packaging_name?\",\n op_class.name AS \"original_packaging_class_name?\",\n items.description\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n LEFT JOIN items AS \"op\"\n ON items.original_packaging = op.id\n LEFT JOIN item_classes AS \"op_class\"\n ON op.class = op_class.id\n WHERE items.id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "short_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "class",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "class_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "original_packaging",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "original_packaging_name?",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "original_packaging_class_name?",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "description",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9c1a1e9c33539f6ec4800c9e8fc8ce3b65f49c34ad848321dc1b151c21cb0043"
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM items WHERE id = $1 RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9dabae553c2a843ed1a96233954665437291f11e2796db9ec7711fc07c39090b"
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n WHERE items.parent = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "class_name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9e1704d0b60906061460e2c972fccf5d87b053857c161651360e5a8fdd80e397"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id AS \"id?\", to_char(short_id, '000000') AS \"short_id?\"\n FROM items\n WHERE id = ANY ($1)",
|
||||
"query": "SELECT id AS \"id?\", to_char(short_id, 'FM000000') AS \"short_id?\"\n FROM items\n WHERE id = ANY ($1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
@ -24,5 +24,5 @@
|
|||
null
|
||||
]
|
||||
},
|
||||
"hash": "460f15b3c5ec2774c237fc581166bf44ecdb0b6145f8abba155478643a474125"
|
||||
"hash": "b7b928c24918aa24f80e4c74eeca04b60bf7295a6d7505ab4897e18b8d7cfbee"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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 ALL\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": "cdb1777fdc044c9b34e82dde93e429177ea5af957398553046590aedd0f4040b"
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM item_events WHERE id = $1 RETURNING item",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "item",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ce9d4ef7aff0cfbbace291d10e459771013e090463d7b858a26df1295e31184a"
|
||||
}
|
|
@ -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": "f16457df9365535d153723fc1ffd8e79849d10798c8d55fa01f99d5245398f6a"
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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": "fecd395ba131e0b0767b8b4799eded503e6bf446c2562cbddae918c971403240"
|
||||
}
|
16
.tarpaulin.toml
Normal file
16
.tarpaulin.toml
Normal file
|
@ -0,0 +1,16 @@
|
|||
# SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
[default]
|
||||
# Tarpaulin uses custom options
|
||||
# that are incompatible with the default options.
|
||||
# This sets a different target directory,
|
||||
# so the files from tarpaulin do not interfere with the regular outputs.
|
||||
target-dir = ".tarpaulin_target"
|
||||
# Do not recompile everything on every run
|
||||
skip-clean = true
|
||||
|
||||
[report]
|
||||
out = ["Html"]
|
||||
output-dir = "target/tarpaulin"
|
585
Cargo.lock
generated
585
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
12
Cargo.toml
12
Cargo.toml
|
@ -8,6 +8,7 @@ version = "0.0.0"
|
|||
authors = ["Simon Bruder <simon@sbruder.de>"]
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
repository = "https://git.sbruder.de/simon/li7y"
|
||||
|
||||
[dependencies]
|
||||
actix-identity = "0.7.1"
|
||||
|
@ -15,7 +16,9 @@ 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"
|
||||
embed-licensing = { version = "0.3.1", features = ["current_platform"] }
|
||||
enum-iterator = "2.1.0"
|
||||
env_logger = "0.11.3"
|
||||
futures-util = "0.3.30"
|
||||
|
@ -29,10 +32,17 @@ rust-embed = { version = "8.5.0", features = ["actix"] }
|
|||
serde = { version = "1.0.203", features = ["serde_derive"] }
|
||||
serde_urlencoded = "0.7.1"
|
||||
serde_variant = "0.1.3"
|
||||
spdx = { version = "0.10.6", features = ["text"] }
|
||||
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"] }
|
||||
|
||||
[dev-dependencies]
|
||||
actix-http = "3.8.0"
|
||||
pretty_assertions = "1.4.0"
|
||||
quickcheck = "1.0.3"
|
||||
quickcheck_macros = "1.0.0"
|
||||
|
||||
[profile.dev.package.sqlx-macros]
|
||||
opt-level = 3
|
||||
|
|
|
@ -10,6 +10,7 @@ allow = [
|
|||
"Apache-2.0",
|
||||
"BSD-3-Clause",
|
||||
"MIT",
|
||||
"MPL-2.0",
|
||||
"Unicode-DFS-2016",
|
||||
]
|
||||
confidence-threshold = 0.95
|
||||
|
|
79
flake.nix
79
flake.nix
|
@ -74,38 +74,79 @@
|
|||
};
|
||||
};
|
||||
|
||||
packages = rec {
|
||||
li7y = naersk'.buildPackage {
|
||||
src = self;
|
||||
packages =
|
||||
let
|
||||
# naersk does not easily allow overrideAttrs
|
||||
commonNaerskConfigurarion = {
|
||||
src = self;
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
postgresql.lib
|
||||
];
|
||||
};
|
||||
default = li7y;
|
||||
checkInputs = with pkgs; [
|
||||
postgresql
|
||||
postgresqlTestHook
|
||||
];
|
||||
|
||||
li7y-oci = pkgs.dockerTools.buildLayeredImage {
|
||||
name = "li7y";
|
||||
tag = "latest";
|
||||
# otherwise the deps derivation is also tested (which just wastes time as there are no tests)
|
||||
overrideMain = (o: o // {
|
||||
doCheck = true;
|
||||
});
|
||||
|
||||
contents = [
|
||||
li7y
|
||||
];
|
||||
# tests need to be able to create and drop databases
|
||||
postgresqlTestUserOptions = "LOGIN SUPERUSER";
|
||||
|
||||
config = {
|
||||
Cmd = [ "${li7y}/bin/li7y" ];
|
||||
postgresqlTestSetupPost = ''
|
||||
export DATABASE_URL="postgres://''${PGUSER}/''${PGDATABASE}?port=5432&host=''${PGHOST}"
|
||||
'';
|
||||
|
||||
# Otherwise SQLx tries to infer the databse schema from an empty database
|
||||
# (as it can only run the migrations once the test binary is built).
|
||||
# Also, this enforces that the full query cache is included in the repository.
|
||||
SQLX_OFFLINE = true;
|
||||
};
|
||||
in
|
||||
rec {
|
||||
li7y = naersk'.buildPackage commonNaerskConfigurarion;
|
||||
default = li7y;
|
||||
|
||||
li7y-tarpaulin = naersk'.buildPackage (commonNaerskConfigurarion // {
|
||||
name = "li7y-tarpaulin";
|
||||
|
||||
checkInputs = commonNaerskConfigurarion.checkInputs ++ (with pkgs; [
|
||||
cargo-tarpaulin
|
||||
]);
|
||||
|
||||
dontBuild = true;
|
||||
singleStep = true; # tarpaulin uses different options anyway
|
||||
|
||||
cargoTestCommands = _: [ "cargo tarpaulin" ];
|
||||
|
||||
postInstall = ''
|
||||
rm -r $out
|
||||
cp -r target/tarpaulin $out
|
||||
'';
|
||||
});
|
||||
|
||||
li7y-oci = pkgs.dockerTools.buildLayeredImage {
|
||||
name = "li7y";
|
||||
tag = "latest";
|
||||
|
||||
contents = [
|
||||
li7y
|
||||
];
|
||||
|
||||
config = {
|
||||
Cmd = [ "${li7y}/bin/li7y" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
rustPackageDev
|
||||
] ++ (with pkgs; [
|
||||
cargo-deny
|
||||
cargo-tarpaulin
|
||||
cargo-watch
|
||||
clippy
|
||||
postgresql.lib
|
||||
graphviz
|
||||
postgresql
|
||||
reuse
|
||||
sqlx-cli
|
||||
|
|
23
migrations/20240721221728_add_item_state.dot
Normal file
23
migrations/20240721221728_add_item_state.dot
Normal 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"];
|
||||
}
|
20
migrations/20240721221728_add_item_state.down.sql
Normal file
20
migrations/20240721221728_add_item_state.down.sql
Normal 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;
|
155
migrations/20240721221728_add_item_state.up.sql
Normal file
155
migrations/20240721221728_add_item_state.up.sql
Normal file
|
@ -0,0 +1,155 @@
|
|||
-- 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');
|
||||
|
||||
RETURN NEW;
|
||||
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;
|
|
@ -0,0 +1,9 @@
|
|||
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
--
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
ALTER TABLE item_events
|
||||
DROP CONSTRAINT item_events_item_fkey;
|
||||
|
||||
ALTER TABLE item_events
|
||||
ADD CONSTRAINT item_events_item_fkey FOREIGN KEY (item) REFERENCES items(id);
|
|
@ -0,0 +1,9 @@
|
|||
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
--
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
ALTER TABLE item_events
|
||||
DROP CONSTRAINT item_events_item_fkey;
|
||||
|
||||
ALTER TABLE item_events
|
||||
ADD CONSTRAINT item_events_item_fkey FOREIGN KEY (item) REFERENCES items(id) ON DELETE CASCADE;
|
|
@ -0,0 +1,28 @@
|
|||
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
--
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
DROP VIEW item_class_tree;
|
||||
|
||||
DROP TRIGGER prevent_item_class_cycle ON item_classes;
|
||||
DROP FUNCTION check_item_class_cycle;
|
||||
|
||||
CREATE FUNCTION check_item_class_recursion_depth()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.parent IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF (SELECT parent FROM item_classes WHERE id = NEW.parent) IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'Item classes may only be nested one level deep';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER prevent_item_class_recursion
|
||||
BEFORE INSERT OR UPDATE ON item_classes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION check_item_class_recursion_depth();
|
50
migrations/20240806120210_item_class_unlimited_depth.up.sql
Normal file
50
migrations/20240806120210_item_class_unlimited_depth.up.sql
Normal file
|
@ -0,0 +1,50 @@
|
|||
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
--
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
DROP TRIGGER prevent_item_class_recursion ON item_classes;
|
||||
DROP FUNCTION check_item_class_recursion_depth;
|
||||
|
||||
CREATE FUNCTION check_item_class_cycle()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.parent IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.id = NEW.parent THEN
|
||||
RAISE EXCEPTION 'Cycle detected';
|
||||
END IF;
|
||||
|
||||
IF (WITH RECURSIVE cte AS (
|
||||
SELECT id, parent
|
||||
FROM item_classes
|
||||
WHERE id = NEW.parent
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT item_classes.id, item_classes.parent
|
||||
FROM item_classes, cte
|
||||
WHERE item_classes.id = cte.parent
|
||||
)
|
||||
SELECT 1
|
||||
FROM cte
|
||||
WHERE parent = NEW.id
|
||||
LIMIT 1) THEN
|
||||
RAISE EXCEPTION 'Cycle detected';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER prevent_item_class_cycle
|
||||
BEFORE INSERT OR UPDATE ON item_classes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION check_item_class_cycle();
|
||||
|
||||
CREATE RECURSIVE VIEW item_class_tree (id, parents) AS (
|
||||
SELECT id, ARRAY[]::UUID[] AS parents FROM item_classes WHERE parent IS NULL
|
||||
UNION ALL
|
||||
SELECT item_classes.id, item_class_tree.parents || item_classes.parent FROM item_classes, item_class_tree WHERE item_classes.parent = item_class_tree.id
|
||||
);
|
34
src/config.rs
Normal file
34
src/config.rs
Normal 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,
|
||||
}
|
38
src/database/item_classes/add.rs
Normal file
38
src/database/item_classes/add.rs
Normal file
|
@ -0,0 +1,38 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde::Deserialize;
|
||||
use sqlx::query_scalar;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ItemClassRepository;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ItemClassAddForm {
|
||||
pub name: String,
|
||||
pub parent: Option<Uuid>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ItemClassAddFormPrefilled {
|
||||
pub name: Option<String>,
|
||||
pub parent: Option<Uuid>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl ItemClassRepository {
|
||||
pub async fn add(&self, data: ItemClassAddForm) -> sqlx::Result<Uuid> {
|
||||
query_scalar!(
|
||||
"INSERT INTO item_classes (name, parent, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id",
|
||||
data.name,
|
||||
data.parent,
|
||||
data.description
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
26
src/database/item_classes/datalist.rs
Normal file
26
src/database/item_classes/datalist.rs
Normal file
|
@ -0,0 +1,26 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::query;
|
||||
|
||||
use super::ItemClassRepository;
|
||||
use crate::frontend::templates::datalist::{Datalist, DatalistOption};
|
||||
|
||||
impl ItemClassRepository {
|
||||
pub async fn datalist(&self) -> sqlx::Result<Datalist> {
|
||||
Ok(Datalist {
|
||||
name: "item-classes".to_string(),
|
||||
link_prefix: Some("/item-class/".to_string()),
|
||||
options: query!("SELECT id, name FROM item_classes")
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| DatalistOption {
|
||||
value: row.id.to_string(),
|
||||
text: Box::new(row.name),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
18
src/database/item_classes/delete.rs
Normal file
18
src/database/item_classes/delete.rs
Normal file
|
@ -0,0 +1,18 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::query;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ItemClassRepository;
|
||||
|
||||
impl ItemClassRepository {
|
||||
pub async fn delete(&self, id: Uuid) -> sqlx::Result<()> {
|
||||
query!("DELETE FROM item_classes WHERE id = $1 RETURNING id", id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
44
src/database/item_classes/edit.rs
Normal file
44
src/database/item_classes/edit.rs
Normal file
|
@ -0,0 +1,44 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde::Deserialize;
|
||||
use sqlx::{query, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ItemClassRepository;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ItemClassEditForm {
|
||||
pub name: String,
|
||||
pub parent: Option<Uuid>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl ItemClassRepository {
|
||||
pub async fn edit_form(&self, id: Uuid) -> sqlx::Result<ItemClassEditForm> {
|
||||
query_as!(
|
||||
ItemClassEditForm,
|
||||
"SELECT name, parent, description FROM item_classes WHERE id = $1",
|
||||
id
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn edit(&self, id: Uuid, data: &ItemClassEditForm) -> sqlx::Result<()> {
|
||||
query!(
|
||||
"UPDATE item_classes
|
||||
SET name = $2, parent = $3, description = $4
|
||||
WHERE id = $1",
|
||||
id,
|
||||
data.name,
|
||||
data.parent,
|
||||
data.description
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
117
src/database/item_classes/list.rs
Normal file
117
src/database/item_classes/list.rs
Normal file
|
@ -0,0 +1,117 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::query;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{ItemClassPreview, ItemClassRepository};
|
||||
|
||||
pub struct ItemClassListEntry {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub parent: Option<ItemClassPreview>,
|
||||
}
|
||||
|
||||
pub struct ItemClassTreeElement {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub children: Vec<ItemClassTreeElement>,
|
||||
}
|
||||
|
||||
impl ItemClassRepository {
|
||||
pub async fn list(&self) -> sqlx::Result<Vec<ItemClassListEntry>> {
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn tree(&self) -> sqlx::Result<Vec<ItemClassTreeElement>> {
|
||||
let mut mappings: HashMap<Uuid, ItemClassTreeElement> = HashMap::new();
|
||||
|
||||
for row in query!(
|
||||
r##"WITH RECURSIVE item_class_children AS (
|
||||
SELECT
|
||||
item_classes.id,
|
||||
array_remove(array_agg(children.id), NULL) AS "children"
|
||||
FROM item_classes
|
||||
LEFT JOIN item_classes AS "children"
|
||||
ON item_classes.id = children.parent
|
||||
GROUP BY item_classes.id
|
||||
),
|
||||
cte AS (
|
||||
SELECT
|
||||
item_classes.id,
|
||||
item_classes.name,
|
||||
item_class_children.children,
|
||||
0 AS "reverse_level"
|
||||
FROM item_classes
|
||||
JOIN item_class_children
|
||||
ON item_classes.id = item_class_children.id
|
||||
WHERE item_class_children.children = '{}'
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
item_classes.id,
|
||||
item_classes.name,
|
||||
item_class_children.children,
|
||||
cte.reverse_level + 1
|
||||
FROM item_classes
|
||||
JOIN item_class_children
|
||||
ON item_classes.id = item_class_children.id
|
||||
JOIN cte
|
||||
ON cte.id = ANY (item_class_children.children)
|
||||
)
|
||||
SELECT
|
||||
id AS "id!",
|
||||
name AS "name!",
|
||||
children AS "children!"
|
||||
FROM cte
|
||||
GROUP BY id, name, children
|
||||
ORDER BY max(reverse_level)"##
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
{
|
||||
let mut children = if row.children.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
row.children
|
||||
.iter()
|
||||
.map(|id| mappings.remove(id).unwrap())
|
||||
.collect()
|
||||
};
|
||||
children.sort_by(|this, other| this.name.cmp(&other.name));
|
||||
|
||||
mappings.insert(
|
||||
row.id,
|
||||
ItemClassTreeElement {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
children,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut item_classes = mappings.into_values().collect::<Vec<_>>();
|
||||
item_classes.sort_by(|this, other| this.name.cmp(&other.name));
|
||||
Ok(item_classes)
|
||||
}
|
||||
}
|
56
src/database/item_classes/mod.rs
Normal file
56
src/database/item_classes/mod.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
mod add;
|
||||
mod datalist;
|
||||
mod delete;
|
||||
mod edit;
|
||||
mod list;
|
||||
mod show;
|
||||
|
||||
use sqlx::{query, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use add::{ItemClassAddForm, ItemClassAddFormPrefilled};
|
||||
pub use edit::ItemClassEditForm;
|
||||
pub use list::ItemClassTreeElement;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ItemClassRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl ItemClassRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ItemClassPreview {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl ItemClassPreview {
|
||||
pub fn new(id: Uuid, name: String) -> Self {
|
||||
Self { id, name }
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemClassRepository {
|
||||
pub async fn parents(&self, id: Uuid) -> sqlx::Result<Vec<ItemClassPreview>> {
|
||||
query!(
|
||||
r#"SELECT item_classes.id, item_classes.name
|
||||
FROM item_classes
|
||||
JOIN unnest((SELECT parents FROM item_class_tree WHERE id = $1))
|
||||
WITH ORDINALITY AS parents(id, n)
|
||||
ON item_classes.id = parents.id
|
||||
ORDER BY parents.n"#,
|
||||
id
|
||||
)
|
||||
.map(|row| ItemClassPreview::new(row.id, row.name))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
78
src/database/item_classes/show.rs
Normal file
78
src/database/item_classes/show.rs
Normal file
|
@ -0,0 +1,78 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::{query, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{ItemClassPreview, ItemClassRepository};
|
||||
use crate::database::item_states::ItemState;
|
||||
use crate::database::items::ItemPreview;
|
||||
|
||||
pub struct ItemClassDetails {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parent: Option<ItemClassPreview>,
|
||||
}
|
||||
|
||||
impl ItemClassRepository {
|
||||
pub async fn details(&self, id: Uuid) -> sqlx::Result<ItemClassDetails> {
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn children(&self, id: Uuid) -> sqlx::Result<Vec<ItemClassPreview>> {
|
||||
query_as!(
|
||||
ItemClassPreview,
|
||||
"SELECT id, name FROM item_classes WHERE parent = $1",
|
||||
id
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn items(&self, id: Uuid) -> sqlx::Result<Vec<ItemPreview>> {
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
96
src/database/item_events.rs
Normal file
96
src/database/item_events.rs
Normal file
|
@ -0,0 +1,96 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{query, query_as, query_scalar, PgPool};
|
||||
use time::Date;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ItemEventRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl ItemEventRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
// Common
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
// Add
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EventAddForm {
|
||||
pub date: Date,
|
||||
pub event: ItemEvent,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl ItemEventRepository {
|
||||
pub async fn add(&self, item: Uuid, data: EventAddForm) -> sqlx::Result<()> {
|
||||
query!(
|
||||
"INSERT INTO item_events (item, date, event, description)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
item,
|
||||
data.date,
|
||||
data.event as ItemEvent,
|
||||
data.description
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
|
||||
impl ItemEventRepository {
|
||||
pub async fn delete(&self, id: i32) -> sqlx::Result<Uuid> {
|
||||
query_scalar!("DELETE FROM item_events WHERE id = $1 RETURNING item", id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// Get
|
||||
|
||||
pub struct ItemEventDetails {
|
||||
pub id: i32,
|
||||
pub date: Date,
|
||||
pub event: ItemEvent,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl ItemEventRepository {
|
||||
pub async fn for_item(&self, item: Uuid) -> sqlx::Result<Vec<ItemEventDetails>> {
|
||||
query_as!(
|
||||
ItemEventDetails,
|
||||
r#"SELECT id, date, event AS "event: ItemEvent", description FROM item_events WHERE item = $1"#,
|
||||
item
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
87
src/database/item_states.rs
Normal file
87
src/database/item_states.rs
Normal file
|
@ -0,0 +1,87 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(test)]
|
||||
use enum_iterator::Sequence;
|
||||
use maud::{html, Markup, Render};
|
||||
#[cfg(test)]
|
||||
use quickcheck::{Arbitrary, Gen};
|
||||
use sqlx::{query, PgPool};
|
||||
|
||||
use crate::frontend::templates::helpers::Colour;
|
||||
|
||||
use super::item_events::ItemEvent;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, sqlx::Type)]
|
||||
#[sqlx(rename_all = "snake_case", type_name = "item_state")]
|
||||
#[cfg_attr(test, derive(Sequence))]
|
||||
pub enum ItemState {
|
||||
Borrowed,
|
||||
Inactive,
|
||||
Loaned,
|
||||
Owned,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Arbitrary for ItemState {
|
||||
fn arbitrary(g: &mut Gen) -> Self {
|
||||
enum_iterator::all::<Self>()
|
||||
.nth(usize::arbitrary(g) % enum_iterator::cardinality::<Self>())
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
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(Clone)]
|
||||
pub struct ItemStateRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl ItemStateRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub async fn possible_events(
|
||||
&self,
|
||||
state: ItemState,
|
||||
) -> sqlx::Result<Vec<(ItemEvent, ItemState)>> {
|
||||
query!(
|
||||
r#"SELECT
|
||||
event AS "event: ItemEvent",
|
||||
next AS "next: ItemState"
|
||||
FROM item_events_transitions
|
||||
WHERE state = $1"#,
|
||||
state as ItemState
|
||||
)
|
||||
.map(|row| (row.event, row.next))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
196
src/database/items/add.rs
Normal file
196
src/database/items/add.rs
Normal file
|
@ -0,0 +1,196 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde::Deserialize;
|
||||
use sqlx::query_scalar;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ItemRepository;
|
||||
|
||||
pub fn default_quantity() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ItemAddForm {
|
||||
#[serde(default = "default_quantity")]
|
||||
pub quantity: usize,
|
||||
pub name: Option<String>,
|
||||
pub parent: Option<Uuid>,
|
||||
pub class: Uuid,
|
||||
pub original_packaging: Option<Uuid>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ItemAddFormPrefilled {
|
||||
pub quantity: Option<usize>,
|
||||
pub name: Option<String>,
|
||||
pub parent: Option<Uuid>,
|
||||
pub class: Option<Uuid>,
|
||||
pub original_packaging: Option<Uuid>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl ItemRepository {
|
||||
pub async fn add(&self, data: ItemAddForm) -> sqlx::Result<Vec<Uuid>> {
|
||||
if data.quantity == 1 {
|
||||
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(&self.pool)
|
||||
.await
|
||||
.map(|id| vec![id])
|
||||
} else {
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use uuid::{uuid, Uuid};
|
||||
|
||||
use crate::database::{items::ItemAddForm, ItemRepository};
|
||||
use crate::test_utils::*;
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn simple_success(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let added_item = repo
|
||||
.add(ItemAddForm {
|
||||
quantity: 1,
|
||||
name: None,
|
||||
parent: None,
|
||||
class: uuid!("e993e21c-8558-49e7-a993-2a6a61c1d55c"),
|
||||
original_packaging: None,
|
||||
description: "descr".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(added_item.len(), 1);
|
||||
|
||||
let added_item = added_item.first();
|
||||
|
||||
assert!(
|
||||
sqlx::query_scalar(
|
||||
"SELECT
|
||||
items.name IS NULL
|
||||
AND items.parent IS NULL
|
||||
AND items.class = 'e993e21c-8558-49e7-a993-2a6a61c1d55c'
|
||||
AND items.original_packaging IS NULL
|
||||
AND items.description = 'descr'
|
||||
FROM items
|
||||
WHERE items.id = $1"
|
||||
)
|
||||
.bind(added_item)
|
||||
.fetch_one(&pool)
|
||||
.await?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn complex_success(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let added_items = repo
|
||||
.add(ItemAddForm {
|
||||
quantity: 7,
|
||||
name: Some("Yeeeet".to_string()),
|
||||
parent: Some(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa")),
|
||||
class: uuid!("e993e21c-8558-49e7-a993-2a6a61c1d55c"),
|
||||
original_packaging: Some(uuid!("554b11ce-fecb-4020-981e-acabbf7b5913")),
|
||||
description: "Lorem ipsum.".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(added_items.len(), 7);
|
||||
|
||||
assert!(
|
||||
sqlx::query_scalar(
|
||||
"SELECT
|
||||
items.name = 'Yeeeet'
|
||||
AND items.parent = '4fc0f5f4-4dca-4c24-844d-1f464cb32afa'
|
||||
AND items.class = 'e993e21c-8558-49e7-a993-2a6a61c1d55c'
|
||||
AND items.original_packaging = '554b11ce-fecb-4020-981e-acabbf7b5913'
|
||||
AND items.description = 'Lorem ipsum.'
|
||||
FROM items
|
||||
WHERE items.id = ANY ($1)"
|
||||
)
|
||||
.bind(added_items)
|
||||
.fetch_one(&pool)
|
||||
.await?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn invalid_references(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let item_count_before = item_count(&pool).await?;
|
||||
|
||||
assert!(repo
|
||||
.add(ItemAddForm {
|
||||
quantity: 1,
|
||||
name: None,
|
||||
parent: Some(Uuid::new_v4()),
|
||||
class: uuid!("e993e21c-8558-49e7-a993-2a6a61c1d55c"),
|
||||
original_packaging: None,
|
||||
description: "".to_string(),
|
||||
})
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
assert!(repo
|
||||
.add(ItemAddForm {
|
||||
quantity: 1,
|
||||
name: None,
|
||||
parent: None,
|
||||
class: Uuid::new_v4(),
|
||||
original_packaging: None,
|
||||
description: "".to_string(),
|
||||
})
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
assert!(repo
|
||||
.add(ItemAddForm {
|
||||
quantity: 1,
|
||||
name: None,
|
||||
parent: None,
|
||||
class: uuid!("e993e21c-8558-49e7-a993-2a6a61c1d55c"),
|
||||
original_packaging: Some(Uuid::new_v4()),
|
||||
description: "".to_string(),
|
||||
})
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
assert_eq!(item_count(&pool).await?, item_count_before);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
63
src/database/items/datalist.rs
Normal file
63
src/database/items/datalist.rs
Normal file
|
@ -0,0 +1,63 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::query;
|
||||
|
||||
use super::{ItemName, ItemRepository};
|
||||
use crate::frontend::templates::datalist::{Datalist, DatalistOption};
|
||||
|
||||
impl ItemRepository {
|
||||
pub async fn datalist(&self) -> sqlx::Result<Datalist> {
|
||||
Ok(Datalist {
|
||||
name: "items".to_string(),
|
||||
link_prefix: Some("/item/".to_string()),
|
||||
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(&self.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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use maud::Render;
|
||||
|
||||
use crate::database::{items::ItemName, ItemRepository};
|
||||
use crate::test_utils::*;
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn datalist(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let dl = repo.datalist().await?;
|
||||
|
||||
assert_eq!(dl.name, "items".to_string());
|
||||
assert_eq!(dl.link_prefix, Some("/item/".to_string()));
|
||||
assert_eq!(dl.options.len(), item_count(&pool).await? as usize);
|
||||
|
||||
// can’t compare Box<dyn Render>
|
||||
let option = dl
|
||||
.options
|
||||
.iter()
|
||||
.find(|option| option.value == "554b11ce-fecb-4020-981e-acabbf7b5913")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
option.text.render().into_string(),
|
||||
ItemName::Item("Item 4".to_string()).render().into_string()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
63
src/database/items/delete.rs
Normal file
63
src/database/items/delete.rs
Normal file
|
@ -0,0 +1,63 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::query;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ItemRepository;
|
||||
|
||||
impl ItemRepository {
|
||||
pub async fn delete(&self, id: Uuid) -> sqlx::Result<()> {
|
||||
query!("DELETE FROM items WHERE id = $1 RETURNING id", id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use uuid::{uuid, Uuid};
|
||||
|
||||
use crate::database::ItemRepository;
|
||||
use crate::test_utils::*;
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn success(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let item_count_before = item_count(&pool).await?;
|
||||
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
repo.delete(uuid!("663f45e6-b11a-4197-8ce4-c784ac9ee617"))
|
||||
.await?;
|
||||
|
||||
assert_eq!(item_count(&pool).await?, item_count_before - 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn prevented_by_constraint(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
assert!(repo
|
||||
.delete(uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26"))
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
repo.delete(uuid!("554b11ce-fecb-4020-981e-acabbf7b5913"))
|
||||
.await?;
|
||||
repo.delete(uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26"))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn invalid_id(pool: sqlx::PgPool) {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
assert!(repo.delete(Uuid::new_v4()).await.is_err());
|
||||
}
|
||||
}
|
147
src/database/items/edit.rs
Normal file
147
src/database/items/edit.rs
Normal file
|
@ -0,0 +1,147 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde::Deserialize;
|
||||
use sqlx::query;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ItemRepository;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ItemEditForm {
|
||||
pub name: Option<String>,
|
||||
pub parent: Option<Uuid>,
|
||||
pub class: Uuid,
|
||||
pub original_packaging: Option<Uuid>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl ItemRepository {
|
||||
pub async fn edit_form(&self, id: Uuid) -> sqlx::Result<ItemEditForm> {
|
||||
query!(
|
||||
r#"SELECT
|
||||
items.name,
|
||||
items.parent,
|
||||
items.class,
|
||||
items.original_packaging,
|
||||
items.description
|
||||
FROM items
|
||||
WHERE items.id = $1"#,
|
||||
id
|
||||
)
|
||||
.map(|row| ItemEditForm {
|
||||
name: row.name,
|
||||
parent: row.parent,
|
||||
class: row.class,
|
||||
original_packaging: row.original_packaging,
|
||||
description: row.description,
|
||||
})
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn edit(&self, id: Uuid, data: &ItemEditForm) -> sqlx::Result<()> {
|
||||
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(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use uuid::uuid;
|
||||
|
||||
use crate::database::{items::ItemEditForm, ItemRepository};
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn prefill_form(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let form = repo
|
||||
.edit_form(uuid!("663f45e6-b11a-4197-8ce4-c784ac9ee617"))
|
||||
.await?;
|
||||
|
||||
assert_eq!(form.name, Some("Item 2".to_string()));
|
||||
assert_eq!(
|
||||
form.parent,
|
||||
Some(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa"))
|
||||
);
|
||||
assert_eq!(form.class, uuid!("8a979306-b4c6-4ef8-900d-68f64abb2975"));
|
||||
assert_eq!(
|
||||
form.original_packaging,
|
||||
Some(uuid!("049298e2-73db-42fb-957d-a741655648b1"))
|
||||
);
|
||||
assert_eq!(form.description, "Lorem ipsum 3".to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn edit(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
repo.edit(
|
||||
uuid!("554b11ce-fecb-4020-981e-acabbf7b5913"),
|
||||
&ItemEditForm {
|
||||
name: Some("Totally new name".to_string()),
|
||||
parent: Some(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa")),
|
||||
class: uuid!("04527cc8-2fbf-4a99-aa0a-361252c8f6d3"),
|
||||
original_packaging: Some(uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26")),
|
||||
description: "never seen before".to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
sqlx::query_scalar(
|
||||
"SELECT
|
||||
items.name = 'Totally new name'
|
||||
AND items.parent = '4fc0f5f4-4dca-4c24-844d-1f464cb32afa'
|
||||
AND items.class = '04527cc8-2fbf-4a99-aa0a-361252c8f6d3'
|
||||
AND items.original_packaging = '3003e61f-0824-4625-9b72-eeb9f11a6a26'
|
||||
AND items.description = 'never seen before'
|
||||
FROM items
|
||||
WHERE items.id = '554b11ce-fecb-4020-981e-acabbf7b5913'"
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn invalid_parameters(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
// cycle
|
||||
assert!(repo
|
||||
.edit(
|
||||
uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa"),
|
||||
&ItemEditForm {
|
||||
name: None,
|
||||
parent: Some(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa")),
|
||||
class: uuid!("8a979306-b4c6-4ef8-900d-68f64abb2975"),
|
||||
original_packaging: None,
|
||||
description: "".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
62
src/database/items/label.rs
Normal file
62
src/database/items/label.rs
Normal file
|
@ -0,0 +1,62 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::query_as;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ItemRepository;
|
||||
use crate::label::LabelPage;
|
||||
|
||||
impl ItemRepository {
|
||||
pub async fn label_pages(&self, ids: &[Uuid]) -> sqlx::Result<Vec<LabelPage>> {
|
||||
query_as!(
|
||||
LabelPage,
|
||||
r#"SELECT id AS "id?", to_char(short_id, 'FM000000') AS "short_id?"
|
||||
FROM items
|
||||
WHERE id = ANY ($1)"#,
|
||||
ids
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use uuid::uuid;
|
||||
|
||||
use crate::database::ItemRepository;
|
||||
use crate::label::LabelPage;
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn success_many(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let items = vec![
|
||||
uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa"),
|
||||
uuid!("049298e2-73db-42fb-957d-a741655648b1"),
|
||||
uuid!("663f45e6-b11a-4197-8ce4-c784ac9ee617"),
|
||||
];
|
||||
|
||||
let short_ids: Vec<i32> =
|
||||
sqlx::query_scalar("SELECT short_id FROM items WHERE id = ANY ($1)")
|
||||
.bind(&items)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
repo.label_pages(&items).await?,
|
||||
items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, id)| LabelPage {
|
||||
id: Some(id),
|
||||
short_id: Some(format!("{:0>6}", short_ids[idx]))
|
||||
})
|
||||
.collect::<Vec<LabelPage>>()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
153
src/database/items/list.rs
Normal file
153
src/database/items/list.rs
Normal file
|
@ -0,0 +1,153 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::query;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ItemRepository;
|
||||
use super::{ItemName, ItemPreview};
|
||||
use crate::database::item_states::ItemState;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ItemListEntry {
|
||||
pub id: Uuid,
|
||||
pub name: ItemName,
|
||||
pub class: Uuid,
|
||||
pub class_name: String,
|
||||
pub parents: Vec<ItemPreview>,
|
||||
pub state: ItemState,
|
||||
}
|
||||
|
||||
impl ItemRepository {
|
||||
pub async fn list(&self) -> sqlx::Result<Vec<ItemListEntry>> {
|
||||
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
|
||||
|
||||
UNION ALL
|
||||
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn previews(&self) -> sqlx::Result<Vec<ItemPreview>> {
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use uuid::uuid;
|
||||
|
||||
use super::ItemListEntry;
|
||||
use crate::database::{
|
||||
item_states::ItemState,
|
||||
items::{ItemName, ItemPreview},
|
||||
ItemRepository,
|
||||
};
|
||||
use crate::test_utils::*;
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn list(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let list = repo.list().await?;
|
||||
|
||||
assert_eq!(list.len(), item_count(&pool).await? as usize);
|
||||
|
||||
assert!(list.contains(&ItemListEntry {
|
||||
id: uuid!("554b11ce-fecb-4020-981e-acabbf7b5913"),
|
||||
name: ItemName::Item("Item 4".to_string()),
|
||||
class: uuid!("e993e21c-8558-49e7-a993-2a6a61c1d55c"),
|
||||
class_name: "Class 1".to_string(),
|
||||
// actual content is tested in test for ItemRepository::parents
|
||||
parents: repo
|
||||
.parents(uuid!("554b11ce-fecb-4020-981e-acabbf7b5913"))
|
||||
.await?,
|
||||
state: ItemState::Owned,
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn previews(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let previews = repo.previews().await?;
|
||||
|
||||
assert_eq!(previews.len(), item_count(&pool).await? as usize);
|
||||
|
||||
assert!(previews.contains(&ItemPreview::new(
|
||||
uuid!("554b11ce-fecb-4020-981e-acabbf7b5913"),
|
||||
ItemName::Item("Item 4".to_string())
|
||||
)));
|
||||
|
||||
assert!(previews.contains(&ItemPreview::new(
|
||||
uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26"),
|
||||
ItemName::Class("Class 1".to_string())
|
||||
)));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
293
src/database/items/mod.rs
Normal file
293
src/database/items/mod.rs
Normal file
|
@ -0,0 +1,293 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
mod add;
|
||||
mod datalist;
|
||||
mod delete;
|
||||
mod edit;
|
||||
mod label;
|
||||
mod list;
|
||||
mod name;
|
||||
mod show;
|
||||
|
||||
use sqlx::{query, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::item_states::ItemState;
|
||||
|
||||
pub use add::{default_quantity, ItemAddForm, ItemAddFormPrefilled};
|
||||
pub use edit::ItemEditForm;
|
||||
pub use name::ItemName;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ItemRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl ItemRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
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,
|
||||
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 ItemRepository {
|
||||
pub async fn parents(&self, id: Uuid) -> sqlx::Result<Vec<ItemPreview>> {
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn children(&self, id: Uuid) -> sqlx::Result<Vec<ItemPreview>> {
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn original_packaging_of(&self, id: Uuid) -> sqlx::Result<Vec<ItemPreview>> {
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use quickcheck_macros::quickcheck;
|
||||
use uuid::{uuid, Uuid};
|
||||
|
||||
use super::ItemPreview;
|
||||
use crate::database::{item_states::ItemState, items::ItemName, ItemRepository};
|
||||
|
||||
#[quickcheck]
|
||||
fn item_preview_new(id: u128, name: ItemName) {
|
||||
let id = Uuid::from_u128(id);
|
||||
assert_eq!(
|
||||
ItemPreview::new(id, name.clone()),
|
||||
ItemPreview {
|
||||
id,
|
||||
name,
|
||||
state: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[quickcheck]
|
||||
fn item_preview_from_parts(id: u128, item_name: Option<String>, class_name: String) {
|
||||
let id = Uuid::from_u128(id);
|
||||
assert_eq!(
|
||||
ItemPreview::from_parts(id, item_name.as_ref(), &class_name),
|
||||
ItemPreview {
|
||||
id,
|
||||
name: ItemName::new(item_name.as_ref(), &class_name),
|
||||
state: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[quickcheck]
|
||||
fn item_preview_with_state(id: u128, name: ItemName, state: ItemState) {
|
||||
let id = Uuid::from_u128(id);
|
||||
assert_eq!(
|
||||
ItemPreview::new(id, name.clone()).with_state(state),
|
||||
ItemPreview {
|
||||
id,
|
||||
name,
|
||||
state: Some(state),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn parents_multiple(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
assert_eq!(
|
||||
repo.parents(uuid!("554b11ce-fecb-4020-981e-acabbf7b5913"))
|
||||
.await?,
|
||||
vec![
|
||||
ItemPreview::new(
|
||||
uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa"),
|
||||
ItemName::Item("Item 1".to_string())
|
||||
),
|
||||
ItemPreview::new(
|
||||
uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26"),
|
||||
ItemName::Class("Class 1".to_string())
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn parents_none(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
assert!(repo
|
||||
.parents(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa"))
|
||||
.await?
|
||||
.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn children_multiple(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let children = repo
|
||||
.children(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa"))
|
||||
.await?;
|
||||
|
||||
let expected = [
|
||||
ItemPreview::new(
|
||||
uuid!("663f45e6-b11a-4197-8ce4-c784ac9ee617"),
|
||||
ItemName::Item("Item 2".to_string()),
|
||||
)
|
||||
.with_state(ItemState::Owned),
|
||||
ItemPreview::new(
|
||||
uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26"),
|
||||
ItemName::Class("Class 1".to_string()),
|
||||
)
|
||||
.with_state(ItemState::Owned),
|
||||
];
|
||||
|
||||
assert_eq!(children.len(), expected.len());
|
||||
|
||||
// can’t use children == expected as order does not matter
|
||||
assert!(children.iter().all(|child| expected.contains(child)));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn children_none(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
assert!(repo
|
||||
.children(uuid!("554b11ce-fecb-4020-981e-acabbf7b5913"))
|
||||
.await?
|
||||
.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn original_packaging_of_multiple(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let original_packaging_of = repo
|
||||
.original_packaging_of(uuid!("049298e2-73db-42fb-957d-a741655648b1"))
|
||||
.await?;
|
||||
|
||||
let expected = [
|
||||
ItemPreview::new(
|
||||
uuid!("663f45e6-b11a-4197-8ce4-c784ac9ee617"),
|
||||
ItemName::Item("Item 2".to_string()),
|
||||
)
|
||||
.with_state(ItemState::Owned),
|
||||
ItemPreview::new(
|
||||
uuid!("4072791f-c5a0-41ac-9e63-2eb1d99b78de"),
|
||||
ItemName::Item("Item 2 companion".to_string()),
|
||||
)
|
||||
.with_state(ItemState::Owned),
|
||||
];
|
||||
|
||||
assert_eq!(original_packaging_of.len(), expected.len());
|
||||
|
||||
// can’t use original_packaging_of == expected as order does not matter
|
||||
assert!(original_packaging_of
|
||||
.iter()
|
||||
.all(|child| expected.contains(child)));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn original_packaging_of_none(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
assert!(repo
|
||||
.original_packaging_of(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa"))
|
||||
.await?
|
||||
.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
107
src/database/items/name.rs
Normal file
107
src/database/items/name.rs
Normal file
|
@ -0,0 +1,107 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(test)]
|
||||
use quickcheck::{Arbitrary, Gen};
|
||||
use sqlx::query;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ItemRepository;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ItemName {
|
||||
Item(String),
|
||||
Class(String),
|
||||
None,
|
||||
}
|
||||
|
||||
impl ItemName {
|
||||
pub fn new(item_name: Option<&String>, class_name: &String) -> Self {
|
||||
if let Some(ref name) = item_name {
|
||||
Self::Item(name.to_string())
|
||||
} else {
|
||||
Self::Class(class_name.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terse(self) -> Self {
|
||||
match self {
|
||||
Self::Item(_) => self,
|
||||
Self::Class(_) | Self::None => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Arbitrary for ItemName {
|
||||
fn arbitrary(g: &mut Gen) -> Self {
|
||||
match u8::arbitrary(g) % 3 {
|
||||
0 => Self::Item(String::arbitrary(g)),
|
||||
1 => Self::Class(String::arbitrary(g)),
|
||||
2 => Self::None,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemRepository {
|
||||
pub async fn name(&self, id: Uuid) -> sqlx::Result<ItemName> {
|
||||
query!(
|
||||
r#"SELECT
|
||||
items.name,
|
||||
item_classes.name AS "class_name"
|
||||
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))
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use uuid::uuid;
|
||||
|
||||
use super::ItemName;
|
||||
use crate::database::ItemRepository;
|
||||
use quickcheck_macros::quickcheck;
|
||||
|
||||
#[quickcheck]
|
||||
fn item_name_is_some(item_name: String, class_name: String) {
|
||||
let name = ItemName::new(Some(&item_name), &class_name);
|
||||
|
||||
assert_eq!(name, ItemName::Item(item_name));
|
||||
assert_eq!(name.clone().terse(), name);
|
||||
}
|
||||
|
||||
#[quickcheck]
|
||||
fn item_name_is_none(class_name: String) {
|
||||
let name = ItemName::new(None, &class_name);
|
||||
|
||||
assert_eq!(name, ItemName::Class(class_name));
|
||||
assert_eq!(name.terse(), ItemName::None)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn success(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
assert_eq!(
|
||||
repo.name(uuid!("4fc0f5f4-4dca-4c24-844d-1f464cb32afa"))
|
||||
.await?,
|
||||
ItemName::Item("Item 1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
repo.name(uuid!("3003e61f-0824-4625-9b72-eeb9f11a6a26"))
|
||||
.await?,
|
||||
ItemName::Class("Class 1".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
119
src/database/items/show.rs
Normal file
119
src/database/items/show.rs
Normal file
|
@ -0,0 +1,119 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::query;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{ItemName, ItemPreview, ItemRepository};
|
||||
use crate::database::item_states::ItemState;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ItemDetails {
|
||||
pub id: Uuid,
|
||||
pub short_id: i32,
|
||||
pub name: ItemName,
|
||||
pub class: Uuid,
|
||||
pub class_name: String,
|
||||
pub original_packaging: Option<ItemPreview>,
|
||||
pub description: String,
|
||||
pub state: ItemState,
|
||||
}
|
||||
|
||||
impl ItemRepository {
|
||||
pub async fn details(&self, id: Uuid) -> sqlx::Result<ItemDetails> {
|
||||
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(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use sqlx::query_scalar;
|
||||
use uuid::uuid;
|
||||
|
||||
use super::ItemDetails;
|
||||
use crate::database::{
|
||||
item_states::ItemState,
|
||||
items::{ItemName, ItemPreview},
|
||||
ItemRepository,
|
||||
};
|
||||
|
||||
#[sqlx::test(fixtures(path = "../../../tests/fixtures", scripts("default")))]
|
||||
async fn success(pool: sqlx::PgPool) -> sqlx::Result<()> {
|
||||
let repo = ItemRepository::new(pool.clone());
|
||||
|
||||
let short_id: i32 = query_scalar(
|
||||
"SELECT short_id FROM items WHERE id = '663f45e6-b11a-4197-8ce4-c784ac9ee617'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
repo.details(uuid!("663f45e6-b11a-4197-8ce4-c784ac9ee617"))
|
||||
.await?,
|
||||
ItemDetails {
|
||||
id: uuid!("663f45e6-b11a-4197-8ce4-c784ac9ee617"),
|
||||
short_id,
|
||||
name: ItemName::Item("Item 2".to_string()),
|
||||
class: uuid!("8a979306-b4c6-4ef8-900d-68f64abb2975"),
|
||||
class_name: "Subclass 1.1".to_string(),
|
||||
original_packaging: Some(
|
||||
ItemPreview::new(
|
||||
uuid!("049298e2-73db-42fb-957d-a741655648b1"),
|
||||
ItemName::Item("Original Packaging of Item 2".to_string())
|
||||
)
|
||||
.with_state(ItemState::Owned)
|
||||
),
|
||||
description: "Lorem ipsum 3".to_string(),
|
||||
state: ItemState::Owned,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
14
src/database/mod.rs
Normal file
14
src/database/mod.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod item_classes;
|
||||
pub mod item_events;
|
||||
pub mod item_states;
|
||||
pub mod items;
|
||||
pub mod search;
|
||||
|
||||
pub use item_classes::ItemClassRepository;
|
||||
pub use item_events::ItemEventRepository;
|
||||
pub use item_states::ItemStateRepository;
|
||||
pub use items::ItemRepository;
|
41
src/database/search.rs
Normal file
41
src/database/search.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::{query, query_scalar, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub enum Entity {
|
||||
Item(Uuid),
|
||||
ItemClass(Uuid),
|
||||
}
|
||||
|
||||
pub async fn search_id(pool: &PgPool, id: &str) -> sqlx::Result<Option<Entity>> {
|
||||
if let Ok(id) = Uuid::parse_str(id) {
|
||||
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" => Entity::Item(id),
|
||||
"item_class" => Entity::ItemClass(id),
|
||||
_ => unreachable!("database returned impossible type"),
|
||||
})
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
} else if let Ok(short_id) = id.parse::<i32>() {
|
||||
Ok(
|
||||
query_scalar!("SELECT id FROM items WHERE short_id = $1", short_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map(|id| Some(Entity::Item(id)))
|
||||
.unwrap_or(None),
|
||||
)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -7,52 +7,31 @@ use std::fmt::Display;
|
|||
use actix_identity::Identity;
|
||||
use actix_web::{error, get, post, web, HttpRequest, Responder};
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{query_scalar, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::frontend::templates::{self, datalist, forms, helpers::PageActionGroup, TemplateConfig};
|
||||
use crate::database::{
|
||||
items::{default_quantity, ItemAddForm, ItemAddFormPrefilled},
|
||||
ItemClassRepository, ItemRepository,
|
||||
};
|
||||
use crate::frontend::templates::{self, forms, helpers::PageActionGroup, TemplateConfig};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get).service(post);
|
||||
}
|
||||
|
||||
fn default_quantity() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NewItemForm {
|
||||
#[serde(default = "default_quantity")]
|
||||
quantity: usize,
|
||||
name: Option<String>,
|
||||
parent: Option<Uuid>,
|
||||
class: Uuid,
|
||||
original_packaging: Option<Uuid>,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NewItemFormPrefilled {
|
||||
quantity: Option<usize>,
|
||||
name: Option<String>,
|
||||
parent: Option<Uuid>,
|
||||
class: Option<Uuid>,
|
||||
original_packaging: Option<Uuid>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/items/add")]
|
||||
async fn get(
|
||||
pool: web::Data<PgPool>,
|
||||
form: web::Query<NewItemFormPrefilled>,
|
||||
item_class_repo: web::Data<ItemClassRepository>,
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
form: web::Query<ItemAddFormPrefilled>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let datalist_items = datalist::items(&pool)
|
||||
let datalist_items = item_repo
|
||||
.datalist()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let datalist_item_classes = datalist::item_classes(&pool)
|
||||
let datalist_item_classes = item_class_repo
|
||||
.datalist()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
|
@ -123,69 +102,47 @@ async fn get(
|
|||
#[post("/items/add")]
|
||||
async fn post(
|
||||
req: HttpRequest,
|
||||
data: web::Form<NewItemForm>,
|
||||
pool: web::Data<PgPool>,
|
||||
data: web::Form<ItemAddForm>,
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let data = data.into_inner();
|
||||
if data.quantity == 1 {
|
||||
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())
|
||||
|
||||
let ids = item_repo
|
||||
.add(data)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)
|
||||
.map(|id| {
|
||||
web::Redirect::to("/item/".to_owned() + &id.to_string())
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
if ids.len() == 1 {
|
||||
Ok(
|
||||
web::Redirect::to("/item/".to_owned() + &ids.first().unwrap().to_string())
|
||||
.see_other()
|
||||
.respond_to(&req)
|
||||
.map_into_boxed_body()
|
||||
})
|
||||
} else {
|
||||
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]
|
||||
.map_into_boxed_body(),
|
||||
)
|
||||
.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) }
|
||||
}
|
||||
} else {
|
||||
Ok(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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,23 +4,24 @@
|
|||
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{error, post, web, Responder};
|
||||
use sqlx::{query, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::ItemRepository;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(post);
|
||||
}
|
||||
|
||||
#[post("/item/{id}/delete")]
|
||||
async fn post(
|
||||
pool: web::Data<PgPool>,
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
path: web::Path<Uuid>,
|
||||
_user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
query!("DELETE FROM items WHERE id = $1", id)
|
||||
.execute(pool.as_ref())
|
||||
item_repo
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
|
|
|
@ -7,68 +7,40 @@ use std::fmt::Display;
|
|||
use actix_identity::Identity;
|
||||
use actix_web::{error, get, post, web, Responder};
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{query, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::frontend::templates::{self, datalist, forms, helpers::ItemName, TemplateConfig};
|
||||
use crate::database::{items::ItemEditForm, ItemClassRepository, ItemRepository};
|
||||
use crate::frontend::templates::{self, forms, TemplateConfig};
|
||||
|
||||
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>,
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
item_class_repo: web::Data<ItemClassRepository>,
|
||||
path: web::Path<Uuid>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
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)
|
||||
let item_name = item_repo
|
||||
.name(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
let form = item_repo
|
||||
.edit_form(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let datalist_item_classes = datalist::item_classes(&pool)
|
||||
let datalist_items = item_repo
|
||||
.datalist()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let datalist_item_classes = item_class_repo
|
||||
.datalist()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
|
@ -149,27 +121,17 @@ async fn get(
|
|||
|
||||
#[post("/item/{id}/edit")]
|
||||
async fn post(
|
||||
pool: web::Data<PgPool>,
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
path: web::Path<Uuid>,
|
||||
data: web::Form<ItemEditForm>,
|
||||
_user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
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)?;
|
||||
item_repo
|
||||
.edit(id, &data)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
Ok(web::Redirect::to("/item/".to_owned() + &id.to_string()).see_other())
|
||||
}
|
||||
|
|
48
src/frontend/item/event.rs
Normal file
48
src/frontend/item/event.rs
Normal file
|
@ -0,0 +1,48 @@
|
|||
// 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 uuid::Uuid;
|
||||
|
||||
use crate::database::item_events::EventAddForm;
|
||||
use crate::database::ItemEventRepository;
|
||||
|
||||
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(
|
||||
item_event_repo: web::Data<ItemEventRepository>,
|
||||
path: web::Path<i32>,
|
||||
_user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
let item_id = item_event_repo
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
Ok(web::Redirect::to(format!("/item/{}", item_id)).see_other())
|
||||
}
|
||||
|
||||
#[post("/item/{id}/events/add")]
|
||||
async fn add(
|
||||
item_event_repo: web::Data<ItemEventRepository>,
|
||||
path: web::Path<Uuid>,
|
||||
data: web::Form<EventAddForm>,
|
||||
_user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
item_event_repo
|
||||
.add(id, data.into_inner())
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
Ok(web::Redirect::to(format!("/item/{}", id)).see_other())
|
||||
}
|
|
@ -4,13 +4,12 @@
|
|||
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{error, get, web, Responder};
|
||||
use maud::html;
|
||||
use sqlx::{query, PgPool};
|
||||
use uuid::Uuid;
|
||||
use maud::{html, Render};
|
||||
|
||||
use crate::database::{items::ItemPreview, ItemRepository};
|
||||
use crate::frontend::templates::{
|
||||
self,
|
||||
helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod},
|
||||
helpers::{Colour, PageAction, PageActionGroup, PageActionMethod},
|
||||
TemplateConfig,
|
||||
};
|
||||
|
||||
|
@ -18,70 +17,15 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||
cfg.service(get);
|
||||
}
|
||||
|
||||
struct ItemListEntry {
|
||||
id: Uuid,
|
||||
name: ItemName,
|
||||
class: Uuid,
|
||||
class_name: String,
|
||||
parents: Vec<ItemPreview>,
|
||||
}
|
||||
|
||||
#[get("/items")]
|
||||
async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> {
|
||||
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
|
||||
|
||||
UNION
|
||||
|
||||
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!"
|
||||
FROM cte
|
||||
JOIN items
|
||||
ON cte.id = items.id
|
||||
JOIN item_classes
|
||||
ON items.class = item_classes.id
|
||||
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(),
|
||||
})
|
||||
.fetch_all(pool.as_ref())
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
async fn get(
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let items = item_repo
|
||||
.list()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
Ok(templates::base(
|
||||
TemplateConfig {
|
||||
|
@ -106,6 +50,7 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
|
|||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "State" }
|
||||
th { "Class" }
|
||||
th { "Parents" }
|
||||
}
|
||||
|
@ -114,8 +59,15 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
|
|||
@for item in items {
|
||||
tr {
|
||||
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)) }
|
||||
td {
|
||||
(templates::helpers::parents_breadcrumb(
|
||||
&item.name,
|
||||
item.parents.iter().map(|parent| parent as &dyn Render).collect(),
|
||||
false
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -4,13 +4,15 @@
|
|||
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{error, get, web, Responder};
|
||||
use maud::html;
|
||||
use sqlx::{query, PgPool};
|
||||
use maud::{html, Render};
|
||||
use serde_variant::to_variant_name;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::{ItemEventRepository, ItemRepository, ItemStateRepository};
|
||||
use crate::frontend::templates::{
|
||||
self,
|
||||
helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod},
|
||||
self, forms,
|
||||
helpers::{Colour, PageAction, PageActionGroup, PageActionMethod},
|
||||
TemplateConfig,
|
||||
};
|
||||
|
||||
|
@ -18,105 +20,45 @@ 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,
|
||||
}
|
||||
|
||||
#[get("/item/{id}")]
|
||||
async fn get(
|
||||
pool: web::Data<PgPool>,
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
item_event_repo: web::Data<ItemEventRepository>,
|
||||
item_state_repo: web::Data<ItemStateRepository>,
|
||||
path: web::Path<Uuid>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
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?",
|
||||
items.description
|
||||
FROM items
|
||||
JOIN item_classes
|
||||
ON items.class = item_classes.id
|
||||
LEFT JOIN items AS "op"
|
||||
ON items.original_packaging = op.id
|
||||
LEFT JOIN item_classes AS "op_class"
|
||||
ON op.class = op_class.id
|
||||
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(),
|
||||
)
|
||||
}),
|
||||
description: row.description,
|
||||
})
|
||||
.fetch_one(pool.as_ref())
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
let item = item_repo
|
||||
.details(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 events = item_event_repo
|
||||
.for_item(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let children = query!(
|
||||
r#"SELECT items.id, items.name, item_classes.name AS "class_name"
|
||||
FROM items
|
||||
JOIN item_classes
|
||||
ON items.class = item_classes.id
|
||||
WHERE items.parent = $1"#,
|
||||
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 possible_events = item_state_repo
|
||||
.possible_events(item.state)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let original_packaging_of = query!(
|
||||
r#"SELECT items.id, items.name, item_classes.name AS "class_name"
|
||||
FROM items
|
||||
JOIN item_classes
|
||||
ON items.class = item_classes.id
|
||||
WHERE items.original_packaging = $1"#,
|
||||
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 parents = item_repo
|
||||
.parents(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let children = item_repo
|
||||
.children(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let original_packaging_of = item_repo
|
||||
.original_packaging_of(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let mut title = item.name.to_string();
|
||||
title.push_str(" – Item Details");
|
||||
|
@ -126,6 +68,7 @@ async fn get(
|
|||
path: &format!("/item/{}", item.id),
|
||||
title: Some(&title),
|
||||
page_title: Some(Box::new(item.name.clone())),
|
||||
page_title_extra: Some(Box::new(item.state)),
|
||||
page_actions: vec![
|
||||
(PageActionGroup::Button {
|
||||
action: PageAction {
|
||||
|
@ -157,40 +100,128 @@ 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(item.name, &parents, true)) }
|
||||
}
|
||||
tr {
|
||||
th { "Original Packaging" }
|
||||
td {
|
||||
@if let Some(original_packaging) = item.original_packaging {
|
||||
(original_packaging)
|
||||
} @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.iter().map(|parent| parent as &dyn Render).collect(),
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,37 +7,25 @@ use std::fmt::Display;
|
|||
use actix_identity::Identity;
|
||||
use actix_web::{error, get, post, web, Responder};
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{query_scalar, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::frontend::templates::{self, datalist, forms, TemplateConfig};
|
||||
use crate::database::{
|
||||
item_classes::{ItemClassAddForm, ItemClassAddFormPrefilled},
|
||||
ItemClassRepository,
|
||||
};
|
||||
use crate::frontend::templates::{self, forms, TemplateConfig};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get).service(post);
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NewItemClassForm {
|
||||
name: String,
|
||||
parent: Option<Uuid>,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NewItemClassFormPrefilled {
|
||||
name: Option<String>,
|
||||
parent: Option<Uuid>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/item-classes/add")]
|
||||
async fn get(
|
||||
pool: web::Data<PgPool>,
|
||||
form: web::Query<NewItemClassFormPrefilled>,
|
||||
item_class_repo: web::Data<ItemClassRepository>,
|
||||
form: web::Query<ItemClassAddFormPrefilled>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let datalist_item_classes = datalist::item_classes(&pool)
|
||||
let datalist_item_classes = item_class_repo
|
||||
.datalist()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
|
@ -86,23 +74,16 @@ async fn get(
|
|||
|
||||
#[post("/item-classes/add")]
|
||||
async fn post(
|
||||
data: web::Form<NewItemClassForm>,
|
||||
pool: web::Data<PgPool>,
|
||||
data: web::Form<ItemClassAddForm>,
|
||||
item_class_repo: web::Data<ItemClassRepository>,
|
||||
_user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let data = data.into_inner();
|
||||
|
||||
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)?;
|
||||
let id = item_class_repo
|
||||
.add(data)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other())
|
||||
}
|
||||
|
|
|
@ -4,23 +4,24 @@
|
|||
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{error, post, web, Responder};
|
||||
use sqlx::{query, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::ItemClassRepository;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(post);
|
||||
}
|
||||
|
||||
#[post("/item-class/{id}/delete")]
|
||||
async fn post(
|
||||
pool: web::Data<PgPool>,
|
||||
item_class_repo: web::Data<ItemClassRepository>,
|
||||
path: web::Path<Uuid>,
|
||||
_user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
query!("DELETE FROM item_classes WHERE id = $1", id)
|
||||
.execute(pool.as_ref())
|
||||
item_class_repo
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
|
|
|
@ -7,41 +7,31 @@ use std::fmt::Display;
|
|||
use actix_identity::Identity;
|
||||
use actix_web::{error, get, post, web, Responder};
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{query, query_as, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::frontend::templates::{self, datalist, forms, TemplateConfig};
|
||||
use crate::database::item_classes::ItemClassEditForm;
|
||||
use crate::database::ItemClassRepository;
|
||||
use crate::frontend::templates::{self, forms, TemplateConfig};
|
||||
|
||||
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>,
|
||||
item_class_repo: web::Data<ItemClassRepository>,
|
||||
path: web::Path<Uuid>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
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 form = item_class_repo
|
||||
.edit_form(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let datalist_item_classes = datalist::item_classes(&pool)
|
||||
let datalist_item_classes = item_class_repo
|
||||
.datalist()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
|
@ -102,25 +92,17 @@ async fn get(
|
|||
|
||||
#[post("/item-class/{id}/edit")]
|
||||
async fn post(
|
||||
pool: web::Data<PgPool>,
|
||||
item_class_repo: web::Data<ItemClassRepository>,
|
||||
path: web::Path<Uuid>,
|
||||
data: web::Form<ItemClassEditForm>,
|
||||
_user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
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)?;
|
||||
item_class_repo
|
||||
.edit(id, &data)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other())
|
||||
}
|
||||
|
|
|
@ -5,12 +5,11 @@
|
|||
use actix_identity::Identity;
|
||||
use actix_web::{error, get, web, Responder};
|
||||
use maud::html;
|
||||
use sqlx::{query, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::ItemClassRepository;
|
||||
use crate::frontend::templates::{
|
||||
self,
|
||||
helpers::{Colour, ItemClassPreview, PageAction, PageActionGroup, PageActionMethod},
|
||||
helpers::{Colour, PageAction, PageActionGroup, PageActionMethod},
|
||||
TemplateConfig,
|
||||
};
|
||||
|
||||
|
@ -18,32 +17,15 @@ 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 = 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)?;
|
||||
async fn get(
|
||||
item_class_repo: web::Data<ItemClassRepository>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let item_classes = item_class_repo
|
||||
.tree()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
Ok(templates::base(
|
||||
TemplateConfig {
|
||||
|
@ -64,26 +46,9 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
|
|||
..Default::default()
|
||||
},
|
||||
html! {
|
||||
table .table {
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Parents" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
@for item_class in item_classes {
|
||||
tr {
|
||||
td { (ItemClassPreview::new(item_class.id, item_class.name)) }
|
||||
td {
|
||||
@if let Some(parent) = item_class.parent {
|
||||
(parent)
|
||||
} @else {
|
||||
"-"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
@for item_class in item_classes {
|
||||
(item_class)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -4,15 +4,13 @@
|
|||
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{error, get, web, Responder};
|
||||
use maud::html;
|
||||
use sqlx::{query, query_as, PgPool};
|
||||
use maud::{html, Render};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::ItemClassRepository;
|
||||
use crate::frontend::templates::{
|
||||
self,
|
||||
helpers::{
|
||||
Colour, ItemClassPreview, ItemPreview, PageAction, PageActionGroup, PageActionMethod,
|
||||
},
|
||||
helpers::{Colour, PageAction, PageActionGroup, PageActionMethod},
|
||||
TemplateConfig,
|
||||
};
|
||||
|
||||
|
@ -20,114 +18,76 @@ 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>,
|
||||
item_class_repo: web::Data<ItemClassRepository>,
|
||||
path: web::Path<Uuid>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let id = path.into_inner();
|
||||
|
||||
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)?;
|
||||
let item_class = item_class_repo
|
||||
.details(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
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 parents = item_class_repo
|
||||
.parents(id)
|
||||
.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
|
||||
WHERE items.class = $1"#,
|
||||
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 children = item_class_repo
|
||||
.children(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let items = item_class_repo
|
||||
.items(id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let mut title = item_class.name.clone();
|
||||
title.push_str(" – Item Details");
|
||||
|
||||
let mut page_actions = vec![
|
||||
(PageActionGroup::Button {
|
||||
action: PageAction {
|
||||
method: PageActionMethod::Get,
|
||||
target: format!("/items/add?class={}", item_class.id),
|
||||
name: "Add Item".to_string(),
|
||||
},
|
||||
colour: Colour::Success,
|
||||
}),
|
||||
];
|
||||
if item_class.parent.is_none() {
|
||||
page_actions.push(PageActionGroup::Button {
|
||||
action: PageAction {
|
||||
method: PageActionMethod::Get,
|
||||
target: format!("/item-classes/add?parent={}", item_class.id),
|
||||
name: "Add Child".to_string(),
|
||||
},
|
||||
colour: Colour::Primary,
|
||||
});
|
||||
}
|
||||
page_actions.push(PageActionGroup::Button {
|
||||
action: PageAction {
|
||||
method: PageActionMethod::Get,
|
||||
target: format!("/item-class/{}/edit", item_class.id),
|
||||
name: "Edit".to_string(),
|
||||
},
|
||||
colour: Colour::Warning,
|
||||
});
|
||||
page_actions.push(PageActionGroup::Button {
|
||||
action: PageAction {
|
||||
method: PageActionMethod::Post,
|
||||
target: format!("/item-class/{}/delete", item_class.id),
|
||||
name: "Delete".to_string(),
|
||||
},
|
||||
colour: Colour::Danger,
|
||||
});
|
||||
|
||||
Ok(templates::base(
|
||||
TemplateConfig {
|
||||
path: &format!("/item-class/{}", item_class.id),
|
||||
title: Some(&title),
|
||||
page_title: Some(Box::new(item_class.name.clone())),
|
||||
page_actions,
|
||||
page_actions: vec![
|
||||
PageActionGroup::Button {
|
||||
action: PageAction {
|
||||
method: PageActionMethod::Get,
|
||||
target: format!("/items/add?class={}", item_class.id),
|
||||
name: "Add Item".to_string(),
|
||||
},
|
||||
colour: Colour::Success,
|
||||
},
|
||||
PageActionGroup::Button {
|
||||
action: PageAction {
|
||||
method: PageActionMethod::Get,
|
||||
target: format!("/item-classes/add?parent={}", item_class.id),
|
||||
name: "Add Child".to_string(),
|
||||
},
|
||||
colour: Colour::Primary,
|
||||
},
|
||||
PageActionGroup::Button {
|
||||
action: PageAction {
|
||||
method: PageActionMethod::Get,
|
||||
target: format!("/item-class/{}/edit", item_class.id),
|
||||
name: "Edit".to_string(),
|
||||
},
|
||||
colour: Colour::Warning,
|
||||
},
|
||||
PageActionGroup::Button {
|
||||
action: PageAction {
|
||||
method: PageActionMethod::Post,
|
||||
target: format!("/item-class/{}/delete", item_class.id),
|
||||
name: "Delete".to_string(),
|
||||
},
|
||||
colour: Colour::Danger,
|
||||
},
|
||||
],
|
||||
user: Some(user),
|
||||
..Default::default()
|
||||
},
|
||||
|
@ -141,10 +101,14 @@ async fn get(
|
|||
th { "Name" }
|
||||
td { (item_class.name) }
|
||||
}
|
||||
@if let Some(parent) = item_class.parent {
|
||||
tr {
|
||||
th { "Parent" }
|
||||
td { (parent) }
|
||||
tr {
|
||||
th { "Parents" }
|
||||
td {
|
||||
(templates::helpers::parents_breadcrumb(
|
||||
&item_class.name,
|
||||
parents.iter().map(|parent| parent as &dyn Render).collect(),
|
||||
true
|
||||
))
|
||||
}
|
||||
}
|
||||
tr {
|
||||
|
|
|
@ -5,19 +5,14 @@
|
|||
use actix_identity::Identity;
|
||||
use actix_web::{error, get, web, Responder};
|
||||
use serde::Deserialize;
|
||||
use sqlx::{query, query_scalar, PgPool};
|
||||
use uuid::Uuid;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::database::search::{search_id, Entity};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub enum EntityType {
|
||||
Item,
|
||||
ItemClass,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JumpData {
|
||||
id: String,
|
||||
|
@ -29,45 +24,18 @@ async fn get(
|
|||
data: web::Query<JumpData>,
|
||||
_user: Identity, // this endpoint leaks information about the existence of items
|
||||
) -> Result<impl Responder, error::Error> {
|
||||
let mut id = data.id.clone();
|
||||
let id = &data.id;
|
||||
|
||||
let entity_type = if let Ok(id) = Uuid::parse_str(&id) {
|
||||
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())
|
||||
let entity = search_id(&pool, &data.id)
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?
|
||||
} else if let Ok(short_id) = id.parse::<i32>() {
|
||||
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 = id_.to_string();
|
||||
Some(EntityType::Item)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
if let Some(prefix) = entity_type.map(|entity_type| match entity_type {
|
||||
EntityType::Item => "item",
|
||||
EntityType::ItemClass => "item-class",
|
||||
}) {
|
||||
Ok(web::Redirect::to(format!("/{prefix}/{id}")).see_other())
|
||||
if let Some(entity) = entity {
|
||||
Ok(web::Redirect::to(match entity {
|
||||
Entity::Item(id) => format!("/item/{id}"),
|
||||
Entity::ItemClass(id) => format!("/item-class/{id}"),
|
||||
})
|
||||
.see_other())
|
||||
} else {
|
||||
Ok(web::Redirect::to(format!(
|
||||
"/items/add?{}",
|
||||
|
|
|
@ -7,11 +7,10 @@ use actix_web::{error, get, post, web, Responder};
|
|||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
use serde_variant::to_variant_name;
|
||||
use sqlx::{query, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::templates::{self, datalist, TemplateConfig};
|
||||
use crate::frontend::templates::helpers::ItemPreview;
|
||||
use super::templates::{self, TemplateConfig};
|
||||
use crate::database::ItemRepository;
|
||||
use crate::label::{Label, LabelPreset};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
|
@ -26,7 +25,10 @@ struct GenerateParams {
|
|||
preset: LabelPreset,
|
||||
}
|
||||
|
||||
async fn generate(pool: &PgPool, params: GenerateParams) -> actix_web::Result<impl Responder> {
|
||||
async fn generate(
|
||||
item_repo: &ItemRepository,
|
||||
params: GenerateParams,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let ids = params
|
||||
.ids
|
||||
.split(',')
|
||||
|
@ -35,43 +37,41 @@ async fn generate(pool: &PgPool, params: GenerateParams) -> actix_web::Result<im
|
|||
.collect::<Result<Vec<Uuid>, uuid::Error>>()
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
Label::for_items(pool, &ids, params.preset.clone().into())
|
||||
Label::for_items(item_repo, &ids, params.preset.clone().into())
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)
|
||||
}
|
||||
|
||||
#[post("/labels/generate")]
|
||||
async fn generate_post(
|
||||
pool: web::Data<PgPool>,
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
_user: Identity,
|
||||
params: web::Form<GenerateParams>,
|
||||
) -> impl Responder {
|
||||
generate(&pool, params.into_inner()).await
|
||||
generate(&item_repo, params.into_inner()).await
|
||||
}
|
||||
|
||||
#[get("/labels/generate")]
|
||||
async fn generate_get(
|
||||
pool: web::Data<PgPool>,
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
_user: Identity,
|
||||
params: web::Query<GenerateParams>,
|
||||
) -> impl Responder {
|
||||
generate(&pool, params.into_inner()).await
|
||||
generate(&item_repo, params.into_inner()).await
|
||||
}
|
||||
|
||||
#[get("/labels")]
|
||||
async fn form(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> {
|
||||
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)?;
|
||||
async fn form(
|
||||
item_repo: web::Data<ItemRepository>,
|
||||
user: Identity,
|
||||
) -> actix_web::Result<impl Responder> {
|
||||
let items = item_repo
|
||||
.previews()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
let datalist_items = datalist::items(&pool)
|
||||
let datalist_items = item_repo
|
||||
.datalist()
|
||||
.await
|
||||
.map_err(error::ErrorInternalServerError)?;
|
||||
|
||||
|
|
488
src/frontend/licensing.rs
Normal file
488
src/frontend/licensing.rs
Normal file
|
@ -0,0 +1,488 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::collections::{BTreeSet, VecDeque};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{get, web, Responder};
|
||||
use embed_licensing::{CrateLicense, Licensing};
|
||||
use maud::{html, Markup, Render};
|
||||
use spdx::expression::ExprNode;
|
||||
use spdx::LicenseReq;
|
||||
|
||||
use super::templates;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialOrd, PartialEq, Eq, Ord)]
|
||||
enum SpdxExpression {
|
||||
Req(LicenseReq),
|
||||
And(BTreeSet<SpdxExpression>),
|
||||
Or(BTreeSet<SpdxExpression>),
|
||||
}
|
||||
|
||||
fn render_separated_list(list: impl IntoIterator<Item: Render>, separator: &str) -> Markup {
|
||||
let mut iter = list.into_iter();
|
||||
html! {
|
||||
"("
|
||||
(iter.next().unwrap())
|
||||
@for item in iter {
|
||||
small .font-monospace { " " (separator) " " }
|
||||
(item)
|
||||
}
|
||||
")"
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: remove the outermost parentheses
|
||||
impl Render for SpdxExpression {
|
||||
fn render(&self) -> Markup {
|
||||
match self {
|
||||
Self::Req(req) => html! {
|
||||
@let license = req.license.id().expect("only SPDX license identifiers supported");
|
||||
a .font-monospace href={ "#license-" (license.name) } { (license.name) }
|
||||
@if let Some(exception) = req.exception {
|
||||
small .font-monospace { " WITH " }
|
||||
a .font-monospace href={ "#exception-" (exception.name) } { (exception.name) }
|
||||
}
|
||||
},
|
||||
Self::And(items) => render_separated_list(items, "AND"),
|
||||
Self::Or(items) => render_separated_list(items, "OR"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&spdx::Expression> for SpdxExpression {
|
||||
fn from(value: &spdx::Expression) -> Self {
|
||||
let mut stack = VecDeque::new();
|
||||
let mut expr = None;
|
||||
|
||||
for node in value.iter() {
|
||||
match node {
|
||||
ExprNode::Op(op) => {
|
||||
let last = expr.unwrap_or_else(|| stack.pop_back().unwrap());
|
||||
expr = Some(match op {
|
||||
spdx::expression::Operator::Or => {
|
||||
SpdxExpression::Or(BTreeSet::from([last, stack.pop_back().unwrap()]))
|
||||
}
|
||||
spdx::expression::Operator::And => {
|
||||
SpdxExpression::And(BTreeSet::from([last, stack.pop_back().unwrap()]))
|
||||
}
|
||||
});
|
||||
}
|
||||
ExprNode::Req(req) => {
|
||||
stack.push_back(SpdxExpression::Req(req.req.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// special case for single req
|
||||
if expr.is_none() && stack.len() == 1 {
|
||||
return stack.pop_back().unwrap();
|
||||
}
|
||||
|
||||
expr.expect("empty expression not possible").simplify()
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! spdx_expression_simplify_impl {
|
||||
( $variant:ident, $items:expr ) => {{
|
||||
let mut changed = false;
|
||||
for item in $items.clone().into_iter() {
|
||||
// if is split to avoid referencing a moved value
|
||||
if let Self::$variant(_) = item {
|
||||
$items.remove(&item);
|
||||
changed = true;
|
||||
}
|
||||
if let Self::$variant(mut inner_items) = item {
|
||||
$items.append(&mut inner_items);
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
Self::$variant($items).simplify()
|
||||
} else {
|
||||
Self::$variant($items.into_iter().map(|it| it.simplify()).collect())
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
impl SpdxExpression {
|
||||
fn simplify(self) -> Self {
|
||||
match self {
|
||||
Self::Req(_) => self,
|
||||
Self::And(mut items) => spdx_expression_simplify_impl!(And, items),
|
||||
Self::Or(mut items) => spdx_expression_simplify_impl!(Or, items),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait Package {
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> Option<&str>;
|
||||
fn authors(&self) -> &[String];
|
||||
fn website(&self) -> &str;
|
||||
fn license(&self) -> &CrateLicense;
|
||||
}
|
||||
|
||||
impl Render for dyn Package {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
div .col-3 {
|
||||
div .card {
|
||||
div .card-body {
|
||||
h4 .card-title {
|
||||
a href=(self.website()) { (self.name()) }
|
||||
@if let Some(version) = self.version() {
|
||||
" " (version)
|
||||
}
|
||||
}
|
||||
}
|
||||
ul .list-group .list-group-flush {
|
||||
@if self.authors().is_empty() {
|
||||
li .list-group-item { em { "no authors specified in Cargo manifest" } }
|
||||
}
|
||||
@let author_limit = 3;
|
||||
@let authors = self.authors().iter().take(author_limit);
|
||||
@let more_authors = self.authors().iter().skip(author_limit);
|
||||
@for author in authors {
|
||||
li .list-group-item { (author) }
|
||||
}
|
||||
@if self.authors().len() > author_limit {
|
||||
@for author in more_authors {
|
||||
li .list-group-item.d-none.author-hidden { (author) }
|
||||
}
|
||||
li
|
||||
.list-group-item.text-center.bg-secondary-subtle.author-expand
|
||||
style="cursor: pointer; user-select: none;"
|
||||
data-expanded="false"
|
||||
{ "⮟" }
|
||||
}
|
||||
}
|
||||
div .card-body.align-content-end {
|
||||
@match self.license() {
|
||||
CrateLicense::SpdxExpression(expr) => {
|
||||
(SpdxExpression::from(expr))
|
||||
},
|
||||
CrateLicense::Other(content) => {
|
||||
@let modal_id = format!("license-modal-{}-{}", self.name(), self.version().unwrap_or(""));
|
||||
button
|
||||
.btn.btn-primary
|
||||
type="button"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target={ "#" (modal_id) }
|
||||
{ "custom license" }
|
||||
div .modal.modal-lg #(modal_id) {
|
||||
div .modal-dialog {
|
||||
div .modal-content {
|
||||
div.modal-header {
|
||||
h1 .modal-title.fs-5 {
|
||||
"custom license of "
|
||||
(self.name())
|
||||
@if let Some(version) = self.version() {
|
||||
" " (version)
|
||||
}
|
||||
}
|
||||
button .btn-close type="button" data-bs-dismiss="modal";
|
||||
}
|
||||
div .modal-body {
|
||||
pre style="text-wrap: wrap" { (content) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Package for embed_licensing::Crate {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn version(&self) -> Option<&str> {
|
||||
Some(&self.version)
|
||||
}
|
||||
|
||||
fn authors(&self) -> &[String] {
|
||||
&self.authors
|
||||
}
|
||||
|
||||
fn website(&self) -> &str {
|
||||
&self.website
|
||||
}
|
||||
|
||||
fn license(&self) -> &CrateLicense {
|
||||
&self.license
|
||||
}
|
||||
}
|
||||
|
||||
struct OtherPackage {
|
||||
name: String,
|
||||
website: String,
|
||||
authors: Vec<String>,
|
||||
license: CrateLicense,
|
||||
}
|
||||
|
||||
impl Package for OtherPackage {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn version(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn authors(&self) -> &[String] {
|
||||
&self.authors
|
||||
}
|
||||
|
||||
fn website(&self) -> &str {
|
||||
&self.website
|
||||
}
|
||||
|
||||
fn license(&self) -> &CrateLicense {
|
||||
&self.license
|
||||
}
|
||||
}
|
||||
|
||||
fn other_packages() -> &'static [OtherPackage] {
|
||||
static OTHER_PACKAGES: OnceLock<Vec<OtherPackage>> = OnceLock::new();
|
||||
|
||||
OTHER_PACKAGES.get_or_init(|| {
|
||||
vec![OtherPackage {
|
||||
name: "Bootstrap".to_string(),
|
||||
website: "https://getbootstrap.com/".to_string(),
|
||||
authors: vec!["The Bootstrap Authors".to_string()],
|
||||
license: CrateLicense::SpdxExpression(spdx::Expression::parse("MIT").unwrap()),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn licensing() -> &'static Licensing {
|
||||
static LICENSING: OnceLock<Licensing> = OnceLock::new();
|
||||
|
||||
LICENSING.get_or_init(|| {
|
||||
let mut licensing = embed_licensing::collect!(platform(current));
|
||||
|
||||
for other_package in other_packages() {
|
||||
if let CrateLicense::SpdxExpression(expr) = &other_package.license {
|
||||
for node in expr.iter() {
|
||||
if let spdx::expression::ExprNode::Req(spdx::expression::ExpressionReq {
|
||||
req,
|
||||
..
|
||||
}) = node
|
||||
{
|
||||
let license = req
|
||||
.license
|
||||
.id()
|
||||
.expect("only SPDX license identifiers supported");
|
||||
|
||||
if !licensing.licenses.contains(&license) {
|
||||
licensing.licenses.push(license)
|
||||
}
|
||||
|
||||
if let Some(exception) = req.exception {
|
||||
if !licensing.exceptions.contains(&exception) {
|
||||
licensing.exceptions.push(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
licensing.licenses.sort_unstable();
|
||||
licensing.exceptions.sort_unstable();
|
||||
|
||||
licensing
|
||||
})
|
||||
}
|
||||
|
||||
#[get("/licensing")]
|
||||
async fn get(user: Option<Identity>) -> impl Responder {
|
||||
let licensing = licensing();
|
||||
|
||||
templates::base(
|
||||
templates::TemplateConfig {
|
||||
path: "/licensing",
|
||||
title: Some("Licensing"),
|
||||
page_title: Some(Box::new("Licensing")),
|
||||
user,
|
||||
..Default::default()
|
||||
},
|
||||
html! {
|
||||
h3 .mt-4 { "Rust Packages" }
|
||||
|
||||
div .row.g-2 {
|
||||
@for package in &licensing.packages {
|
||||
(package as &dyn Package)
|
||||
}
|
||||
}
|
||||
|
||||
h3 .mt-4 { "Other Packages" }
|
||||
|
||||
div .row.g-2 {
|
||||
@for package in other_packages() {
|
||||
(package as &dyn Package)
|
||||
}
|
||||
}
|
||||
|
||||
h3 .mt-4 { "Licenses" }
|
||||
|
||||
@for license in &licensing.licenses {
|
||||
h4 #{ "license-" (license.name) } .mt-3 { (license.full_name) " (" span .font-monospace { (license.name) } ")" }
|
||||
|
||||
pre style="text-wrap: wrap" { (license.text()) }
|
||||
}
|
||||
|
||||
h3 .mt-4 { "Exceptions" }
|
||||
|
||||
@for exception in &licensing.exceptions {
|
||||
h4 #{ "exception-" (exception.name) } .mt-3.font-monospace { (exception.name) }
|
||||
|
||||
pre style="text-wrap: wrap" { (exception.text()) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use spdx::{exception_id, license_id, LicenseItem, LicenseReq};
|
||||
|
||||
use super::SpdxExpression;
|
||||
|
||||
fn req(id: &str, or_later: bool, exception: Option<&str>) -> SpdxExpression {
|
||||
SpdxExpression::Req(LicenseReq {
|
||||
license: LicenseItem::Spdx {
|
||||
id: license_id(id).unwrap(),
|
||||
or_later,
|
||||
},
|
||||
exception: exception.map(exception_id).map(Option::unwrap),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spdx_expression_from_simple() {
|
||||
assert_eq!(
|
||||
SpdxExpression::from(&spdx::Expression::parse("MIT").unwrap()),
|
||||
req("MIT", false, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spdx_expression_from_complex() {
|
||||
assert_eq!(
|
||||
SpdxExpression::from(
|
||||
&spdx::Expression::parse("Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT OR (CC0-1.0 AND Unlicense)")
|
||||
.unwrap()
|
||||
),
|
||||
SpdxExpression::Or(BTreeSet::from([
|
||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
||||
req("Apache-2.0", false, None),
|
||||
req("MIT", false, None),
|
||||
SpdxExpression::And(BTreeSet::from([
|
||||
req("CC0-1.0", false, None),
|
||||
req("Unlicense", false, None),
|
||||
]))
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spdx_expression_simplify_or() {
|
||||
assert_eq!(
|
||||
SpdxExpression::Or(BTreeSet::from([
|
||||
SpdxExpression::Or(BTreeSet::from([])),
|
||||
SpdxExpression::Or(BTreeSet::from([
|
||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
||||
req("MIT", false, None),
|
||||
SpdxExpression::Or(BTreeSet::from([
|
||||
req("MIT", false, None),
|
||||
req("Unlicense", false, None),
|
||||
])),
|
||||
])),
|
||||
req("LGPL-2.1", true, None),
|
||||
]))
|
||||
.simplify(),
|
||||
SpdxExpression::Or(BTreeSet::from([
|
||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
||||
req("LGPL-2.1", true, None),
|
||||
req("MIT", false, None),
|
||||
req("Unlicense", false, None),
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spdx_expression_simplify_and() {
|
||||
assert_eq!(
|
||||
SpdxExpression::And(BTreeSet::from([
|
||||
SpdxExpression::And(BTreeSet::from([])),
|
||||
SpdxExpression::And(BTreeSet::from([
|
||||
req("MIT", false, None),
|
||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
||||
SpdxExpression::And(BTreeSet::from([
|
||||
req("MIT", false, None),
|
||||
req("Unlicense", false, None),
|
||||
])),
|
||||
])),
|
||||
req("LGPL-2.1", true, None),
|
||||
]))
|
||||
.simplify(),
|
||||
SpdxExpression::And(BTreeSet::from([
|
||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
||||
req("LGPL-2.1", true, None),
|
||||
req("MIT", false, None),
|
||||
req("Unlicense", false, None),
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spdx_expression_simplify_mixed() {
|
||||
assert_eq!(
|
||||
SpdxExpression::Or(BTreeSet::from([
|
||||
SpdxExpression::And(BTreeSet::from([
|
||||
req("MIT", false, None),
|
||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
||||
])),
|
||||
SpdxExpression::Or(BTreeSet::from([
|
||||
req("MIT", false, None),
|
||||
req("Unlicense", false, None),
|
||||
])),
|
||||
SpdxExpression::And(BTreeSet::from([
|
||||
req("CC0-1.0", false, None),
|
||||
req("GPL-3.0", false, None),
|
||||
])),
|
||||
req("LGPL-2.1", true, None),
|
||||
]))
|
||||
.simplify(),
|
||||
SpdxExpression::Or(BTreeSet::from([
|
||||
req("MIT", false, None),
|
||||
req("Unlicense", false, None),
|
||||
req("LGPL-2.1", true, None),
|
||||
SpdxExpression::And(BTreeSet::from([
|
||||
req("MIT", false, None),
|
||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
||||
])),
|
||||
SpdxExpression::And(BTreeSet::from([
|
||||
req("CC0-1.0", false, None),
|
||||
req("GPL-3.0", false, None),
|
||||
])),
|
||||
]))
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,7 +7,8 @@ mod item;
|
|||
mod item_class;
|
||||
mod jump;
|
||||
mod labels;
|
||||
mod templates;
|
||||
mod licensing;
|
||||
pub mod templates;
|
||||
|
||||
use actix_identity::Identity;
|
||||
use actix_web::{get, web, Responder};
|
||||
|
@ -19,7 +20,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||
.configure(item::config)
|
||||
.configure(item_class::config)
|
||||
.configure(jump::config)
|
||||
.configure(labels::config);
|
||||
.configure(labels::config)
|
||||
.configure(licensing::config);
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
|
|
|
@ -3,14 +3,11 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{html, Markup, Render};
|
||||
use sqlx::{query, PgPool};
|
||||
|
||||
use super::helpers::ItemName;
|
||||
|
||||
pub struct Datalist {
|
||||
name: String,
|
||||
options: Vec<DatalistOption>,
|
||||
link_prefix: Option<String>,
|
||||
pub name: String,
|
||||
pub options: Vec<DatalistOption>,
|
||||
pub link_prefix: Option<String>,
|
||||
}
|
||||
|
||||
impl Datalist {
|
||||
|
@ -32,8 +29,8 @@ impl Render for Datalist {
|
|||
}
|
||||
|
||||
pub struct DatalistOption {
|
||||
value: String,
|
||||
text: Box<dyn Render>,
|
||||
pub value: String,
|
||||
pub text: Box<dyn Render>,
|
||||
}
|
||||
|
||||
impl Render for DatalistOption {
|
||||
|
@ -41,40 +38,3 @@ impl Render for DatalistOption {
|
|||
html! { option value=(self.value) { (self.text) } }
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn items(pool: &PgPool) -> Result<Datalist, sqlx::Error> {
|
||||
Ok(Datalist {
|
||||
name: "items".to_string(),
|
||||
link_prefix: Some("/item/".to_string()),
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
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: query!("SELECT id, name FROM item_classes")
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| DatalistOption {
|
||||
value: row.id.to_string(),
|
||||
text: Box::new(row.name),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
@ -2,12 +2,13 @@
|
|||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::fmt::{self, Display};
|
||||
use std::fmt;
|
||||
|
||||
use crate::label::LabelPreset;
|
||||
use maud::{html, Markup, PreEscaped, Render};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::label::LabelPreset;
|
||||
|
||||
pub enum Css<'a> {
|
||||
File(&'a str),
|
||||
#[allow(dead_code)]
|
||||
|
@ -78,101 +79,12 @@ impl fmt::Display for Colour {
|
|||
}
|
||||
|
||||
impl Colour {
|
||||
fn button(&self) -> String {
|
||||
pub fn button(&self) -> String {
|
||||
format!("btn-{self}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ItemName {
|
||||
Item(String),
|
||||
Class(String),
|
||||
None,
|
||||
}
|
||||
|
||||
impl ItemName {
|
||||
pub fn new(item_name: Option<&String>, class_name: &String) -> Self {
|
||||
if let Some(ref name) = item_name {
|
||||
Self::Item(name.to_string())
|
||||
} else {
|
||||
Self::Class(class_name.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemName {
|
||||
pub fn terse(self) -> Self {
|
||||
match self {
|
||||
Self::Item(_) => self,
|
||||
Self::Class(_) | Self::None => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ItemName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Item(name) => write!(f, "{name}"),
|
||||
Self::Class(name) => write!(f, "*{name}*"),
|
||||
Self::None => write!(f, "[no name]"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ItemName {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
@match self {
|
||||
Self::Item(name) => { (name) },
|
||||
Self::Class(name) => { em { (name) } },
|
||||
Self::None => { em { "[no name]" } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ItemPreview {
|
||||
pub id: Uuid,
|
||||
pub name: ItemName,
|
||||
}
|
||||
|
||||
impl ItemPreview {
|
||||
pub fn new(id: Uuid, name: ItemName) -> Self {
|
||||
Self { id, name }
|
||||
}
|
||||
|
||||
pub fn from_parts(id: Uuid, item_name: Option<&String>, class_name: &String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: ItemName::new(item_name, class_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ItemPreview {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
a href={ "/item/" (self.id) } { (self.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
pub fn text_bg(&self) -> String {
|
||||
format!("text-bg-{self}")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -257,7 +169,7 @@ impl PageActionGroup {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn parents_breadcrumb(name: ItemName, parents: &[ItemPreview], full: bool) -> Markup {
|
||||
pub fn parents_breadcrumb(name: &dyn Render, parents: Vec<&dyn Render>, full: bool) -> Markup {
|
||||
const LIMIT: usize = 3;
|
||||
|
||||
html! {
|
||||
|
@ -265,10 +177,10 @@ pub fn parents_breadcrumb(name: ItemName, parents: &[ItemPreview], full: bool) -
|
|||
@if !full && parents.len() > LIMIT {
|
||||
li .breadcrumb-item { "…" }
|
||||
}
|
||||
@let parents: Box<dyn Iterator<Item = &ItemPreview>> = if full {
|
||||
Box::new(parents.iter())
|
||||
@let parents: Box<dyn Iterator<Item = &dyn Render>> = if full {
|
||||
Box::new(parents.into_iter())
|
||||
} else {
|
||||
Box::new(parents.iter().rev().take(LIMIT).rev())
|
||||
Box::new(parents.into_iter().rev().take(LIMIT).rev())
|
||||
};
|
||||
@for parent in parents {
|
||||
li .breadcrumb-item {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
pub mod datalist;
|
||||
pub mod forms;
|
||||
pub mod helpers;
|
||||
mod render;
|
||||
|
||||
use actix_identity::Identity;
|
||||
use maud::{html, Markup, Render, DOCTYPE};
|
||||
|
@ -58,7 +59,13 @@ fn footer() -> Markup {
|
|||
html! {
|
||||
footer .mt-auto.py-3.bg-body-tertiary.text-body-tertiary {
|
||||
div .container {
|
||||
p .mb-0 { "li7y is free software, released under the terms of the AGPL v3" }
|
||||
p .mb-0 {
|
||||
"li7y is free software, released under the terms of the AGPL v3 ("
|
||||
a href="https://git.sbruder.de/simon/li7y" { "source code" }
|
||||
", "
|
||||
a href="/licensing" { "licensing of all dependencies" }
|
||||
")"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -68,6 +75,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>>,
|
||||
|
@ -81,6 +89,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(),
|
||||
|
@ -115,19 +124,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 .float-end.d-flex.align-items-start.h-100.gap-1 {
|
||||
@for page_action in config.page_actions {
|
||||
(page_action)
|
||||
}
|
||||
}
|
||||
div {
|
||||
@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)
|
||||
|
|
91
src/frontend/templates/render.rs
Normal file
91
src/frontend/templates/render.rs
Normal file
|
@ -0,0 +1,91 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
use maud::{html, Markup, Render};
|
||||
|
||||
use crate::database::items::ItemPreview;
|
||||
use crate::database::{
|
||||
item_classes::{ItemClassPreview, ItemClassTreeElement},
|
||||
item_events::ItemEvent,
|
||||
items::ItemName,
|
||||
};
|
||||
|
||||
impl Render for ItemClassPreview {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
a href={ "/item-class/" (self.id) } { (self.name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ItemName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Item(name) => write!(f, "{name}"),
|
||||
Self::Class(name) => write!(f, "*{name}*"),
|
||||
Self::None => write!(f, "[no name]"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ItemName {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
@match self {
|
||||
Self::Item(name) => { (name) },
|
||||
Self::Class(name) => { em { (name) } },
|
||||
Self::None => { em { "[no name]" } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ItemPreview {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
a .me-1[self.state.is_some()] href={ "/item/" (self.id) } { (self.name) }
|
||||
@if let Some(ref state) = self.state {
|
||||
(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ItemClassTreeElement {
|
||||
fn render(&self) -> Markup {
|
||||
html! {
|
||||
li {
|
||||
(ItemClassPreview::new(self.id, self.name.clone()))
|
||||
@if !self.children.is_empty() {
|
||||
ul {
|
||||
@for child in &self.children {
|
||||
(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,12 +12,13 @@ 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;
|
||||
|
||||
pub use preset::LabelPreset;
|
||||
|
||||
use crate::database::ItemRepository;
|
||||
|
||||
const FONT: Cursor<&[u8]> = Cursor::new(include_bytes!(
|
||||
"../../assets/fonts/IosevkaLi7y-Regular.subset.ttf"
|
||||
));
|
||||
|
@ -32,6 +33,8 @@ pub enum Error {
|
|||
Io(#[from] std::io::Error),
|
||||
#[error("PDF error: {0}")]
|
||||
PrintPdf(#[from] printpdf::Error),
|
||||
#[error("SQLx error: {0}")]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
|
||||
#[error("data is incomplete ({0} not given in data, but required in config)")]
|
||||
DataIncomplete(String),
|
||||
|
@ -168,7 +171,7 @@ impl TextConfig {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct LabelPage {
|
||||
pub id: Option<Uuid>,
|
||||
pub short_id: Option<String>,
|
||||
|
@ -247,17 +250,13 @@ impl Label {
|
|||
Ok(doc.ok_or(Error::NoPages)?.save_to_bytes()?)
|
||||
}
|
||||
|
||||
pub async fn for_items(pool: &PgPool, ids: &[Uuid], config: LabelConfig) -> sqlx::Result<Self> {
|
||||
pub async fn for_items(
|
||||
item_repo: &ItemRepository,
|
||||
ids: &[Uuid],
|
||||
config: LabelConfig,
|
||||
) -> 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?,
|
||||
pages: item_repo.label_pages(ids).await?,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
|
82
src/lib.rs
82
src/lib.rs
|
@ -2,6 +2,88 @@
|
|||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
mod config;
|
||||
pub mod database;
|
||||
pub mod frontend;
|
||||
pub mod label;
|
||||
pub mod middleware;
|
||||
#[cfg(test)]
|
||||
mod test_utils;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
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};
|
||||
use base64::prelude::{Engine as _, BASE64_STANDARD};
|
||||
use log::warn;
|
||||
use mime_guess::from_path;
|
||||
use rust_embed::Embed;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::database::{
|
||||
ItemClassRepository, ItemEventRepository, ItemRepository, ItemStateRepository,
|
||||
};
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "static"]
|
||||
struct Static;
|
||||
|
||||
pub fn app(
|
||||
config: &Config,
|
||||
pool: &PgPool,
|
||||
) -> App<
|
||||
impl actix_web::dev::ServiceFactory<
|
||||
actix_web::dev::ServiceRequest,
|
||||
Config = (),
|
||||
Response = actix_web::dev::ServiceResponse<
|
||||
actix_web::body::EitherBody<actix_web::body::BoxBody>,
|
||||
>,
|
||||
Error = actix_web::Error,
|
||||
InitError = (),
|
||||
>,
|
||||
> {
|
||||
let secret_key = match config.secret_key {
|
||||
Some(ref encoded) => Key::from(
|
||||
&BASE64_STANDARD
|
||||
.decode(encoded)
|
||||
.expect("failed to decode base64 in SECRET_KEY"),
|
||||
),
|
||||
None => {
|
||||
warn!("SECRET_KEY was not specified, using randomly generated key");
|
||||
Key::generate()
|
||||
}
|
||||
};
|
||||
|
||||
App::new()
|
||||
.app_data(web::Data::new(config.clone()))
|
||||
.app_data(web::Data::new(pool.clone()))
|
||||
.app_data(web::Data::new(ItemClassRepository::new(pool.clone())))
|
||||
.app_data(web::Data::new(ItemEventRepository::new(pool.clone())))
|
||||
.app_data(web::Data::new(ItemStateRepository::new(pool.clone())))
|
||||
.app_data(web::Data::new(ItemRepository::new(pool.clone())))
|
||||
.service(web::scope("/static").route(
|
||||
"/{_:.*}",
|
||||
web::get().to(|path: web::Path<String>| async {
|
||||
Static::get(&path)
|
||||
.map(|embedded_file| match from_path(path.into_inner()).first() {
|
||||
Some(mime_type) => HttpResponse::Ok()
|
||||
.content_type(mime_type)
|
||||
.body(embedded_file.data),
|
||||
None => HttpResponse::Ok().body(embedded_file.data),
|
||||
})
|
||||
.unwrap_or(HttpResponse::NotFound().body(()))
|
||||
}),
|
||||
))
|
||||
.configure(frontend::config)
|
||||
.wrap(ErrorHandlers::new().handler(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
middleware::error_handlers::redirect_to_login,
|
||||
))
|
||||
.wrap(IdentityMiddleware::default())
|
||||
.wrap(SessionMiddleware::new(
|
||||
CookieSessionStore::default(),
|
||||
secret_key.clone(),
|
||||
))
|
||||
}
|
||||
|
|
79
src/main.rs
79
src/main.rs
|
@ -2,84 +2,35 @@
|
|||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::env;
|
||||
use actix_web::HttpServer;
|
||||
use clap::Parser;
|
||||
use log::info;
|
||||
|
||||
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 log::{info, warn};
|
||||
use mime_guess::from_path;
|
||||
use rust_embed::Embed;
|
||||
|
||||
#[derive(Embed)]
|
||||
#[folder = "static"]
|
||||
struct Static;
|
||||
use li7y::Config;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
// generate a secret key with head -c 64 /dev/urandom | base64 -w0
|
||||
let secret_key = match env::var("SECRET_KEY") {
|
||||
Ok(encoded) => Key::from(
|
||||
&BASE64_STANDARD
|
||||
.decode(encoded)
|
||||
.expect("failed to decode base64 in SECRET_KEY"),
|
||||
),
|
||||
Err(_) => {
|
||||
warn!("SECRET_KEY was not specified, using randomly generated key");
|
||||
Key::generate()
|
||||
}
|
||||
};
|
||||
let config = Config::parse();
|
||||
|
||||
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");
|
||||
// This can’t be included in app, because app gets called in a (non-async) closure
|
||||
let pool = 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(pool.clone()))
|
||||
.service(web::scope("/static").route(
|
||||
"/{_:.*}",
|
||||
web::get().to(|path: web::Path<String>| async {
|
||||
Static::get(&path)
|
||||
.map(|embedded_file| match from_path(path.into_inner()).first() {
|
||||
Some(mime_type) => HttpResponse::Ok()
|
||||
.content_type(mime_type)
|
||||
.body(embedded_file.data),
|
||||
None => HttpResponse::Ok().body(embedded_file.data),
|
||||
})
|
||||
.unwrap_or(HttpResponse::NotFound().body(()))
|
||||
}),
|
||||
))
|
||||
.configure(li7y::frontend::config)
|
||||
.wrap(ErrorHandlers::new().handler(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
li7y::middleware::error_handlers::redirect_to_login,
|
||||
))
|
||||
.wrap(IdentityMiddleware::default())
|
||||
.wrap(SessionMiddleware::new(
|
||||
CookieSessionStore::default(),
|
||||
secret_key.clone(),
|
||||
))
|
||||
})
|
||||
.bind((address, port))?
|
||||
.run()
|
||||
.await
|
||||
HttpServer::new(move || li7y::app(&config, &pool))
|
||||
.bind((address, port))?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
|
11
src/test_utils.rs
Normal file
11
src/test_utils.rs
Normal file
|
@ -0,0 +1,11 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sqlx::query_scalar;
|
||||
|
||||
pub async fn item_count(pool: &sqlx::PgPool) -> sqlx::Result<i64> {
|
||||
query_scalar("SELECT count(id) FROM items")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
|
@ -16,9 +16,8 @@
|
|||
|
||||
const datalistHint = (input, hint) => {
|
||||
const selected = input.list.querySelector(`option[value="${input.value}"]`);
|
||||
if (selected === null)
|
||||
hint.innerText = ""
|
||||
else {
|
||||
hint.innerHTML = ""
|
||||
if (selected !== null) {
|
||||
const linkPrefix = input.list.dataset.linkPrefix;
|
||||
if (linkPrefix === undefined) {
|
||||
hint.innerHTML = selected.innerHTML
|
||||
|
@ -85,4 +84,33 @@
|
|||
.join(",")
|
||||
})
|
||||
}
|
||||
|
||||
const addEventModal = document.getElementById("add-event-modal")
|
||||
if (addEventModal !== null) {
|
||||
addEventModal.addEventListener("show.bs.modal", e => {
|
||||
document.getElementById("event").value = e.relatedTarget.dataset.eventType
|
||||
})
|
||||
}
|
||||
|
||||
Array.from(document.getElementsByClassName("author-expand")).forEach(expanderEl => {
|
||||
expanderEl.addEventListener("click", _ => {
|
||||
// it implicitly converts true/false to "true"/"false" in the dataset,
|
||||
// but does not convert "true"/"false" from the datast into true/false
|
||||
expanderEl.dataset.expanded = expanderEl.dataset.expanded !== "true"
|
||||
|
||||
if (expanderEl.dataset.expanded === "true") {
|
||||
expanderEl.innerText = "⮝"
|
||||
} else {
|
||||
expanderEl.innerText = "⮟"
|
||||
}
|
||||
|
||||
Array.from(expanderEl.parentElement.getElementsByClassName("author-hidden")).forEach(authorEl => {
|
||||
if (expanderEl.dataset.expanded === "true") {
|
||||
authorEl.classList.remove("d-none")
|
||||
} else {
|
||||
authorEl.classList.add("d-none")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})()
|
||||
|
|
77
tests/auth.rs
Normal file
77
tests/auth.rs
Normal file
|
@ -0,0 +1,77 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use actix_http::header;
|
||||
use actix_web::{cookie::Cookie, test};
|
||||
use sqlx::PgPool;
|
||||
|
||||
mod common;
|
||||
|
||||
#[sqlx::test]
|
||||
async fn protected_route_requires_login(pool: PgPool) {
|
||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
||||
let req = test::TestRequest::get().uri("/items").to_request();
|
||||
let res = test::call_service(&srv, req).await;
|
||||
|
||||
assert!(common::assert_redirect(res.map_into_boxed_body()).starts_with("/login"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn login(pool: PgPool) {
|
||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
||||
|
||||
// This is identical to common::session_cookie,
|
||||
// but copied here explicitly to ensure the right functionality is tested.
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/login")
|
||||
.set_form(common::LoginForm::default())
|
||||
.to_request();
|
||||
|
||||
let res = test::call_service(&srv, req).await;
|
||||
let session = Cookie::parse_encoded(
|
||||
res.headers()
|
||||
.clone()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(common::assert_redirect(res.map_into_boxed_body()).starts_with("/"));
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/")
|
||||
.cookie(session.clone())
|
||||
.to_request();
|
||||
|
||||
let res = test::call_service(&srv, req).await;
|
||||
|
||||
assert!(res.status().is_success());
|
||||
}
|
||||
|
||||
#[ignore = "actix_session::CookieSessionStore does not support invalidating sessions"]
|
||||
#[sqlx::test]
|
||||
async fn logout(pool: PgPool) {
|
||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
||||
|
||||
let session_cookie = common::session_cookie(&srv).await;
|
||||
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/logout")
|
||||
.cookie(session_cookie.clone())
|
||||
.to_request();
|
||||
|
||||
test::call_service(&srv, req).await;
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/items")
|
||||
.cookie(session_cookie.clone())
|
||||
.to_request();
|
||||
|
||||
let res = test::call_service(&srv, req).await;
|
||||
|
||||
assert!(common::assert_redirect(res.map_into_boxed_body()).starts_with("/login"));
|
||||
}
|
69
tests/common/mod.rs
Normal file
69
tests/common/mod.rs
Normal file
|
@ -0,0 +1,69 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::env;
|
||||
|
||||
use actix_http::header;
|
||||
use actix_web::{cookie::Cookie, dev::ServiceResponse, test};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
|
||||
use li7y::Config;
|
||||
|
||||
pub const SUPERUSER_PASSWORD: &str = "correct horse battery staple";
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginForm {
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl Default for LoginForm {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
password: SUPERUSER_PASSWORD.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config() -> Config {
|
||||
env::set_var("SUPERUSER_PASSWORD", SUPERUSER_PASSWORD);
|
||||
Config::parse_from(Vec::<std::ffi::OsString>::new().iter())
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // for some reason rustc detects this as unused
|
||||
pub fn assert_redirect(res: ServiceResponse) -> String {
|
||||
assert!(res.status().is_redirection());
|
||||
|
||||
res.headers()
|
||||
.get(header::LOCATION)
|
||||
.expect("No location header set when expected")
|
||||
.to_str()
|
||||
.expect("Location header is not valid UTF-8")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub async fn session_cookie<'a>(
|
||||
srv: &impl actix_web::dev::Service<
|
||||
actix_http::Request,
|
||||
Response = ServiceResponse<actix_web::body::EitherBody<actix_web::body::BoxBody>>,
|
||||
Error = actix_web::Error,
|
||||
>,
|
||||
) -> Cookie<'a> {
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/login")
|
||||
.set_form(LoginForm::default())
|
||||
.to_request();
|
||||
|
||||
Cookie::parse_encoded(
|
||||
test::call_service(&srv, req)
|
||||
.await
|
||||
.headers()
|
||||
.get(header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
34
tests/fixtures/default.sql
vendored
Normal file
34
tests/fixtures/default.sql
vendored
Normal file
|
@ -0,0 +1,34 @@
|
|||
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
--
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
INSERT INTO item_classes (id, name, parent, description) VALUES
|
||||
('e993e21c-8558-49e7-a993-2a6a61c1d55c', 'Class 1', NULL, 'Lorem ipsum 1'),
|
||||
('04527cc8-2fbf-4a99-aa0a-361252c8f6d3', 'Class 2', NULL, 'Lorem ipsum 2'),
|
||||
('9d760792-ddb0-47a0-bed1-c27dc41b285b', 'Class 3', NULL, 'Lorem ipsum 3'),
|
||||
('8a979306-b4c6-4ef8-900d-68f64abb2975', 'Subclass 1.1', 'e993e21c-8558-49e7-a993-2a6a61c1d55c', 'Lorem ipsum 4'),
|
||||
('042fe283-f645-401c-9079-3bd3ab1c3dc9', 'Subclass 1.2', 'e993e21c-8558-49e7-a993-2a6a61c1d55c', 'Lorem ipsum 5'),
|
||||
('01ad10ec-d3be-4346-9d44-4ebb7297a14d', 'Subclass 2.1', '04527cc8-2fbf-4a99-aa0a-361252c8f6d3', 'Lorem ipsum 6');
|
||||
|
||||
INSERT INTO items (id, name, parent, class, original_packaging, description) VALUES
|
||||
('4fc0f5f4-4dca-4c24-844d-1f464cb32afa', 'Item 1', NULL, 'e993e21c-8558-49e7-a993-2a6a61c1d55c', NULL, 'Lorem ipsum 1'),
|
||||
('049298e2-73db-42fb-957d-a741655648b1', 'Original Packaging of Item 2', NULL, '01ad10ec-d3be-4346-9d44-4ebb7297a14d', NULL, 'Lorem ipsum 2'),
|
||||
('663f45e6-b11a-4197-8ce4-c784ac9ee617', 'Item 2', '4fc0f5f4-4dca-4c24-844d-1f464cb32afa', '8a979306-b4c6-4ef8-900d-68f64abb2975', '049298e2-73db-42fb-957d-a741655648b1', 'Lorem ipsum 3'),
|
||||
('4072791f-c5a0-41ac-9e63-2eb1d99b78de', 'Item 2 companion', '049298e2-73db-42fb-957d-a741655648b1', '042fe283-f645-401c-9079-3bd3ab1c3dc9', '049298e2-73db-42fb-957d-a741655648b1', 'Lorem ipsum 10'),
|
||||
('3003e61f-0824-4625-9b72-eeb9f11a6a26', NULL, '4fc0f5f4-4dca-4c24-844d-1f464cb32afa', 'e993e21c-8558-49e7-a993-2a6a61c1d55c', NULL, 'Lorem ipsum 4'),
|
||||
('554b11ce-fecb-4020-981e-acabbf7b5913', 'Item 4', '3003e61f-0824-4625-9b72-eeb9f11a6a26', 'e993e21c-8558-49e7-a993-2a6a61c1d55c', NULL, 'Lorem ipsum 5'),
|
||||
('b9fce434-faa4-4242-bd06-9d3589fa41e7', 'Borrowed Item', NULL, '9d760792-ddb0-47a0-bed1-c27dc41b285b', NULL, 'Lorem ipsum 6'),
|
||||
('2683d77f-2d9c-4a5c-b87f-6e1a99c69db0', 'Loaned Item', NULL, '9d760792-ddb0-47a0-bed1-c27dc41b285b', NULL, 'Lorem ipsum 7'),
|
||||
('5ca9ed99-2e70-4723-9ae4-0bb5ab274366', 'Inactive Item', NULL, '9d760792-ddb0-47a0-bed1-c27dc41b285b', NULL, 'Lorem ipsum 8'),
|
||||
('2da2643d-c759-48ab-8cdf-e4d46c8ecc69', 'Owned Item (bought)', NULL, '9d760792-ddb0-47a0-bed1-c27dc41b285b', NULL, 'Lorem ipsum 9');
|
||||
|
||||
DELETE FROM item_events WHERE item = ANY (ARRAY[
|
||||
'b9fce434-faa4-4242-bd06-9d3589fa41e7',
|
||||
'2da2643d-c759-48ab-8cdf-e4d46c8ecc69'
|
||||
]::uuid[]);
|
||||
|
||||
INSERT INTO item_events (item, event, description) VALUES
|
||||
('b9fce434-faa4-4242-bd06-9d3589fa41e7', 'borrow', 'from Jane Person'),
|
||||
('2683d77f-2d9c-4a5c-b87f-6e1a99c69db0', 'loan', 'to Joe Person'),
|
||||
('5ca9ed99-2e70-4723-9ae4-0bb5ab274366', 'gift', 'to Jude Person'),
|
||||
('2da2643d-c759-48ab-8cdf-e4d46c8ecc69', 'buy', 'from garage sale');
|
71
tests/items.rs
Normal file
71
tests/items.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use actix_web::{body::MessageBody, test};
|
||||
use sqlx::{query_as, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
mod common;
|
||||
|
||||
#[sqlx::test(fixtures("default"))]
|
||||
async fn list(pool: PgPool) {
|
||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
||||
|
||||
let session_cookie = common::session_cookie(&srv).await;
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/items")
|
||||
.cookie(session_cookie.clone())
|
||||
.to_request();
|
||||
|
||||
let res = test::call_service(&srv, req).await;
|
||||
|
||||
assert!(res.status().is_success());
|
||||
|
||||
let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap();
|
||||
|
||||
let items: Vec<(Uuid, Option<String>)> = query_as("SELECT id, name FROM items")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (id, name) in items {
|
||||
assert!(body.contains(&format!(r#"href="/item/{id}""#)));
|
||||
|
||||
if let Some(name) = name {
|
||||
assert!(body.contains(&format!(">{name}</a>")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("default"))]
|
||||
async fn show(pool: PgPool) {
|
||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
||||
|
||||
let session_cookie = common::session_cookie(&srv).await;
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/item/663f45e6-b11a-4197-8ce4-c784ac9ee617")
|
||||
.cookie(session_cookie.clone())
|
||||
.to_request();
|
||||
|
||||
let res = test::call_service(&srv, req).await;
|
||||
|
||||
assert!(res.status().is_success());
|
||||
|
||||
let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap();
|
||||
|
||||
assert!(body.contains("<h2>Item 2 <"));
|
||||
assert!(body.contains("<th>UUID</th><td>663f45e6-b11a-4197-8ce4-c784ac9ee617</td>"));
|
||||
assert!(body.contains("<th>Name</th><td>Item 2</td>"));
|
||||
assert!(body
|
||||
.contains(r#"href="/item-class/8a979306-b4c6-4ef8-900d-68f64abb2975">Subclass 1.1</a>"#));
|
||||
assert!(body.contains(r#"href="/item/4fc0f5f4-4dca-4c24-844d-1f464cb32afa">Item 1</a>"#));
|
||||
assert!(body.contains(r#"<li class="breadcrumb-item active">Item 2</li>"#));
|
||||
assert!(body.contains(
|
||||
r#"href="/item/049298e2-73db-42fb-957d-a741655648b1">Original Packaging of Item 2</a>"#
|
||||
));
|
||||
assert!(body.contains(">Lorem ipsum 3</td>"));
|
||||
assert!(body.contains(">acquire</"));
|
||||
}
|
49
tests/licensing.rs
Normal file
49
tests/licensing.rs
Normal file
|
@ -0,0 +1,49 @@
|
|||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use actix_web::{body::MessageBody, test};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod common;
|
||||
|
||||
#[sqlx::test(fixtures("default"))]
|
||||
async fn is_linked(pool: PgPool) {
|
||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
||||
|
||||
let req = test::TestRequest::get().uri("/login").to_request();
|
||||
|
||||
let res = test::call_service(&srv, req).await;
|
||||
|
||||
assert!(res.status().is_success());
|
||||
|
||||
let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap();
|
||||
|
||||
assert!(body.contains(r#"<a href="/licensing">"#));
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("default"))]
|
||||
async fn contains_basic_information(pool: PgPool) {
|
||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
||||
|
||||
let req = test::TestRequest::get().uri("/licensing").to_request();
|
||||
|
||||
let res = test::call_service(&srv, req).await;
|
||||
|
||||
assert!(res.status().is_success());
|
||||
|
||||
let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap();
|
||||
|
||||
// crate names
|
||||
assert!(body.contains(">actix-web<"));
|
||||
assert!(body.contains(">sqlx-postgres<"));
|
||||
|
||||
// author names (only me and entries without email address)
|
||||
assert!(body.contains(">Simon Bruder <simon@sbruder.de><"));
|
||||
assert!(body.contains(">RustCrypto Developers<"));
|
||||
|
||||
// license links (only partial, as I don’t want to check class names)
|
||||
assert!(body.contains(r##"href="#license-Apache-2.0">Apache-2.0<"##));
|
||||
assert!(body.contains(r##"href="#license-MIT">MIT<"##));
|
||||
}
|
Loading…
Reference in a new issue