Compare commits

..

22 commits

Author SHA1 Message Date
Simon Bruder b9022ed125
Update dependencies
All checks were successful
/ build (push) Successful in 1m3s
2024-08-28 15:29:39 +02:00
Simon Bruder dfa233dd3c
Remove duplicate clippy in devShell
It is already included in rust-bin.
2024-08-28 15:29:39 +02:00
Simon Bruder 659a6cb776
Add licensing information page 2024-08-28 15:29:38 +02:00
Simon Bruder bbca62fa8b
Fix event add modal handler registration 2024-08-28 15:29:37 +02:00
Simon Bruder 89ce5b7d40
Ensure datalist hint is cleared 2024-08-28 15:29:36 +02:00
Simon Bruder 6007e4b96a
Show item class parents with breadcrumbs 2024-08-28 15:29:36 +02:00
Simon Bruder 61f356269c
Generalize parents_breadcrumb 2024-08-28 15:29:35 +02:00
Simon Bruder 413a02cdaa
Show item classes as tree 2024-08-28 15:29:34 +02:00
Simon Bruder ae4e583c2d
Allow unlimited nesting of item classes 2024-08-06 17:11:44 +02:00
Simon Bruder cdc73d1ac5
Update dependencies
All checks were successful
/ build (push) Successful in 3m15s
2024-07-28 14:41:54 +02:00
Simon Bruder bd1e7ad407
Add basic integration tests
All checks were successful
/ build (push) Successful in 1m19s
2024-07-28 13:31:08 +02:00
Simon Bruder b22588cd0d
Move app creation to lib.rs
All checks were successful
/ build (push) Successful in 1m55s
This makes integration testing much easier as it can reuse the app
instance.
2024-07-27 22:15:58 +02:00
Simon Bruder 79c4ab6c2b
Add unit tests for database::items
All checks were successful
/ build (push) Successful in 5s
2024-07-27 20:45:30 +02:00
Simon Bruder 2e39ef952b
Add testing infrastructure 2024-07-27 20:45:17 +02:00
Simon Bruder 6e4e8d3d93
Fix formatting of short ID on label
PostgreSQL adds a space in front of the number if not specified
otherwise, to account for a minus sign with negative numbers. As all
short IDs are positive, it can be suppressed here.
2024-07-27 16:15:14 +02:00
Simon Bruder 716ac1a698
Remove postgresql.lib buildInput
All checks were successful
/ build (push) Successful in 2m13s
It is no longer required with SQLx.
2024-07-25 23:23:00 +02:00
Simon Bruder 61a49afaf7
Make item (class) delete fail on invalid ID 2024-07-25 23:22:59 +02:00
Simon Bruder c4107266c4
Delete item event with item 2024-07-25 23:22:58 +02:00
Simon Bruder f33f3c8b9c
Move database operations to separate module
This can be seen as partially reverting
e83bc8316e, which moved everything to the
place where it was used.
2024-07-25 23:22:56 +02:00
Simon Bruder a320f3834a
Improve page title layout with long titles
All checks were successful
/ build (push) Successful in 26s
2024-07-24 16:44:30 +02:00
Simon Bruder 1e5f7930ab
Use clap for configuration 2024-07-24 16:44:29 +02:00
Simon Bruder bcbb7dfc67
Add item state 2024-07-24 16:29:22 +02:00
78 changed files with 3972 additions and 1342 deletions

View file

@ -20,7 +20,7 @@ jobs:
git config --unset "http.${GITHUB_SERVER_URL}/.extraHeader" git config --unset "http.${GITHUB_SERVER_URL}/.extraHeader"
git lfs install --local git lfs install --local
git lfs pull git lfs pull
- name: Build - name: Build and test
run: nix build -L .#li7y .#li7y-oci run: nix build -L .#li7y .#li7y-oci
- name: Push OCI image - name: Push OCI image
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'

1
.gitignore vendored
View file

@ -3,5 +3,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
/target /target
/.tarpaulin_target
result* result*
.pre-commit-config.yaml .pre-commit-config.yaml

View file

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

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -22,5 +22,5 @@
false false
] ]
}, },
"hash": "857bbaea0fa73c37679d927aa627ee841c5b89c86af12f32493ba05c0f0ead91" "hash": "087164492bb5f51006e5091f9382b5e27a5a90908d65b7a6f6d6587f8a3e2a10"
} }

View file

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM items WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "08cce122c8eb9b390e5411cc08d2e735de7230a596fa2f2ba472bed6a9b4a75c"
}

View file

@ -1,74 +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 item_states.state AS \"state!: ItemState\"\n FROM cte\n JOIN items\n ON cte.id = items.id\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n ORDER BY items.created_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "parents!",
"type_info": "UuidArray"
},
{
"ordinal": 5,
"name": "parent_names!: Vec<Option<String>>",
"type_info": "VarcharArray"
},
{
"ordinal": 6,
"name": "parent_class_names!",
"type_info": "VarcharArray"
},
{
"ordinal": 7,
"name": "state!: ItemState",
"type_info": {
"Custom": {
"name": "item_state",
"kind": {
"Enum": [
"borrowed",
"inactive",
"loaned",
"owned"
]
}
}
}
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
true,
false,
false,
null,
null,
null,
true
]
},
"hash": "3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee"
}

View file

@ -0,0 +1,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"
}

View file

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

View file

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM item_classes WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "7a811ef54e617ed88ef9c7be88b48ca0da2204c64200ffa77a51f0c580f9ab87"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -20,16 +20,11 @@
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "original_packaging", "name": "original_packaging",
"type_info": "Uuid" "type_info": "Uuid"
}, },
{ {
"ordinal": 5, "ordinal": 4,
"name": "description", "name": "description",
"type_info": "Varchar" "type_info": "Varchar"
} }
@ -43,10 +38,9 @@
true, true,
true, true,
false, false,
false,
true, true,
false false
] ]
}, },
"hash": "c49b88eda9a62743783bc894f01bb6198594f94a3e0856abde0efdb4e49dbab8" "hash": "7b1265f893e68df4ebde549fed8dc1872c723d1294521a72d9d2251f4ddd8da6"
} }

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -34,5 +34,5 @@
false false
] ]
}, },
"hash": "c7e35dee5f56da8da3c083e191f396b95917b15768ebe0250ce7840391036616" "hash": "8d9766469e77b291eceffc2ba17ff78c11283a29411233597bd27a52a934a595"
} }

View file

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

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -24,5 +24,5 @@
null null
] ]
}, },
"hash": "460f15b3c5ec2774c237fc581166bf44ecdb0b6145f8abba155478643a474125" "hash": "b7b928c24918aa24f80e4c74eeca04b60bf7295a6d7505ab4897e18b8d7cfbee"
} }

View file

@ -0,0 +1,74 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH RECURSIVE cte AS (\n SELECT\n id,\n ARRAY[]::UUID[] AS parents,\n ARRAY[]::VARCHAR[] AS parent_names,\n ARRAY[]::VARCHAR[] AS parent_class_names\n FROM items\n WHERE parent IS NULL\n\n UNION 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"
}

View file

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

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT\n event AS \"event: ItemEvent\",\n next AS \"next: ItemState\"\n FROM item_events_transitions\n WHERE state = $1", "query": "SELECT\n event AS \"event: ItemEvent\",\n next AS \"next: ItemState\"\n FROM item_events_transitions\n WHERE state = $1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -68,5 +68,5 @@
false false
] ]
}, },
"hash": "1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c" "hash": "fecd395ba131e0b0767b8b4799eded503e6bf446c2562cbddae918c971403240"
} }

16
.tarpaulin.toml Normal file
View 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"

546
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ version = "0.0.0"
authors = ["Simon Bruder <simon@sbruder.de>"] authors = ["Simon Bruder <simon@sbruder.de>"]
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
repository = "https://git.sbruder.de/simon/li7y"
[dependencies] [dependencies]
actix-identity = "0.7.1" actix-identity = "0.7.1"
@ -17,6 +18,7 @@ barcoders = { version = "2.0.0", default-features = false, features = ["std"] }
base64 = "0.22.1" base64 = "0.22.1"
clap = { version = "4.5.10", features = ["derive", "env"] } clap = { version = "4.5.10", features = ["derive", "env"] }
datamatrix = "0.3.1" datamatrix = "0.3.1"
embed-licensing = { version = "0.3.1", features = ["current_platform"] }
enum-iterator = "2.1.0" enum-iterator = "2.1.0"
env_logger = "0.11.3" env_logger = "0.11.3"
futures-util = "0.3.30" futures-util = "0.3.30"
@ -30,10 +32,17 @@ rust-embed = { version = "8.5.0", features = ["actix"] }
serde = { version = "1.0.203", features = ["serde_derive"] } serde = { version = "1.0.203", features = ["serde_derive"] }
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
serde_variant = "0.1.3" serde_variant = "0.1.3"
spdx = { version = "0.10.6", features = ["text"] }
sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] } sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] }
thiserror = "1.0.61" thiserror = "1.0.61"
time = { version = "0.3.36", features = ["parsing", "serde"] } time = { version = "0.3.36", features = ["parsing", "serde"] }
uuid = { version = "1.9.0", features = ["serde", "v4"] } uuid = { version = "1.9.0", features = ["serde", "v4"] }
[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] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3

View file

@ -10,6 +10,7 @@ allow = [
"Apache-2.0", "Apache-2.0",
"BSD-3-Clause", "BSD-3-Clause",
"MIT", "MIT",
"MPL-2.0",
"Unicode-DFS-2016", "Unicode-DFS-2016",
] ]
confidence-threshold = 0.95 confidence-threshold = 0.95

View file

@ -74,39 +74,79 @@
}; };
}; };
packages = rec { packages =
li7y = naersk'.buildPackage { let
src = self; # naersk does not easily allow overrideAttrs
commonNaerskConfigurarion = {
src = self;
buildInputs = with pkgs; [ checkInputs = with pkgs; [
postgresql.lib postgresql
]; postgresqlTestHook
}; ];
default = li7y;
li7y-oci = pkgs.dockerTools.buildLayeredImage { # otherwise the deps derivation is also tested (which just wastes time as there are no tests)
name = "li7y"; overrideMain = (o: o // {
tag = "latest"; doCheck = true;
});
contents = [ # tests need to be able to create and drop databases
li7y postgresqlTestUserOptions = "LOGIN SUPERUSER";
];
config = { postgresqlTestSetupPost = ''
Cmd = [ "${li7y}/bin/li7y" ]; 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 { devShells.default = pkgs.mkShell {
buildInputs = [ buildInputs = [
rustPackageDev rustPackageDev
] ++ (with pkgs; [ ] ++ (with pkgs; [
cargo-deny cargo-deny
cargo-tarpaulin
cargo-watch cargo-watch
clippy
graphviz graphviz
postgresql.lib
postgresql postgresql
reuse reuse
sqlx-cli sqlx-cli

View file

@ -141,6 +141,8 @@ RETURNS TRIGGER AS $$
BEGIN BEGIN
INSERT INTO item_events (item, event, description) INSERT INTO item_events (item, event, description)
VALUES (NEW.id, 'acquire', 'automatically added on item insert'); VALUES (NEW.id, 'acquire', 'automatically added on item insert');
RETURN NEW;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;

View file

@ -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);

View file

@ -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;

View file

@ -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();

View 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
);

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

View 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(),
})
}
}

View 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(())
}
}

View 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(())
}
}

View 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)
}
}

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

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

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

View 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
View 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(())
}
}

View 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);
// cant 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(())
}
}

View 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
View 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(())
}
}

View 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
View 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
View 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());
// cant 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());
// cant 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
View 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
View 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
View 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
View 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)
}
}

View file

@ -7,52 +7,31 @@ use std::fmt::Display;
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, post, web, HttpRequest, Responder}; use actix_web::{error, get, post, web, HttpRequest, Responder};
use maud::html; 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) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post); 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")] #[get("/items/add")]
async fn get( async fn get(
pool: web::Data<PgPool>, item_class_repo: web::Data<ItemClassRepository>,
form: web::Query<NewItemFormPrefilled>, item_repo: web::Data<ItemRepository>,
form: web::Query<ItemAddFormPrefilled>,
user: Identity, user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let datalist_items = datalist::items(&pool) let datalist_items = item_repo
.datalist()
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
let datalist_item_classes = datalist::item_classes(&pool) let datalist_item_classes = item_class_repo
.datalist()
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
@ -123,69 +102,47 @@ async fn get(
#[post("/items/add")] #[post("/items/add")]
async fn post( async fn post(
req: HttpRequest, req: HttpRequest,
data: web::Form<NewItemForm>, data: web::Form<ItemAddForm>,
pool: web::Data<PgPool>, item_repo: web::Data<ItemRepository>,
user: Identity, user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let data = data.into_inner(); let data = data.into_inner();
if data.quantity == 1 {
query_scalar!( let ids = item_repo
"INSERT INTO items (name, parent, class, original_packaging, description) .add(data)
VALUES ($1, $2, $3, $4, $5)
RETURNING id",
data.name,
data.parent,
data.class,
data.original_packaging,
data.description
)
.fetch_one(pool.as_ref())
.await .await
.map_err(error::ErrorInternalServerError) .map_err(error::ErrorInternalServerError)?;
.map(|id| {
web::Redirect::to("/item/".to_owned() + &id.to_string()) if ids.len() == 1 {
Ok(
web::Redirect::to("/item/".to_owned() + &ids.first().unwrap().to_string())
.see_other() .see_other()
.respond_to(&req) .respond_to(&req)
.map_into_boxed_body() .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]
) )
.fetch_all(pool.as_ref()) } else {
.await Ok(templates::base(
.map_err(error::ErrorInternalServerError) TemplateConfig {
.map(|ids| { path: "/items/add",
templates::base( title: Some("Added Items"),
TemplateConfig { page_title: Some(Box::new("Added Items")),
path: "/items/add", page_actions: vec![PageActionGroup::generate_labels(&ids)],
title: Some("Added Items"), user: Some(user),
page_title: Some(Box::new("Added Items")), ..Default::default()
page_actions: vec![PageActionGroup::generate_labels(&ids)], },
user: Some(user), html! {
..Default::default() ul {
}, @for id in &ids {
html! { li {
ul { a href={ "/item/" (id) } { (id) }
@for id in &ids {
li {
a href={ "/item/" (id) } { (id) }
}
} }
} }
}
a href="/items" { "Back to all items" } a href="/items" { "Back to all items" }
}, },
) )
.respond_to(&req) .respond_to(&req)
.map_into_boxed_body() .map_into_boxed_body())
})
} }
} }

View file

@ -4,23 +4,24 @@
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, post, web, Responder}; use actix_web::{error, post, web, Responder};
use sqlx::{query, PgPool};
use uuid::Uuid; use uuid::Uuid;
use crate::database::ItemRepository;
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(post); cfg.service(post);
} }
#[post("/item/{id}/delete")] #[post("/item/{id}/delete")]
async fn post( async fn post(
pool: web::Data<PgPool>, item_repo: web::Data<ItemRepository>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
_user: Identity, _user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
query!("DELETE FROM items WHERE id = $1", id) item_repo
.execute(pool.as_ref()) .delete(id)
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;

View file

@ -7,68 +7,40 @@ use std::fmt::Display;
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder}; use actix_web::{error, get, post, web, Responder};
use maud::html; use maud::html;
use serde::Deserialize;
use sqlx::{query, PgPool};
use uuid::Uuid; 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) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post); 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")] #[get("/item/{id}/edit")]
async fn get( async fn get(
pool: web::Data<PgPool>, item_repo: web::Data<ItemRepository>,
item_class_repo: web::Data<ItemClassRepository>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
user: Identity, user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
let (item_name, form) = query!( let item_name = item_repo
r#"SELECT .name(id)
items.name, .await
items.parent, .map_err(error::ErrorInternalServerError)?;
items.class, let form = item_repo
item_classes.name AS "class_name", .edit_form(id)
items.original_packaging,
items.description
FROM items
JOIN item_classes
ON items.class = item_classes.id
WHERE items.id = $1"#,
id
)
.map(|row| {
(
ItemName::new(row.name.as_ref(), &row.class_name),
ItemEditForm {
name: row.name,
parent: row.parent,
class: row.class,
original_packaging: row.original_packaging,
description: row.description,
},
)
})
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_items = datalist::items(&pool)
.await .await
.map_err(error::ErrorInternalServerError)?; .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 .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
@ -149,27 +121,17 @@ async fn get(
#[post("/item/{id}/edit")] #[post("/item/{id}/edit")]
async fn post( async fn post(
pool: web::Data<PgPool>, item_repo: web::Data<ItemRepository>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
data: web::Form<ItemEditForm>, data: web::Form<ItemEditForm>,
_user: Identity, _user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
query!( item_repo
"UPDATE items .edit(id, &data)
SET name = $2, parent = $3, class = $4, original_packaging = $5, description = $6 .await
WHERE id = $1", .map_err(error::ErrorInternalServerError)?;
id,
data.name,
data.parent,
data.class,
data.original_packaging,
data.description
)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item/".to_owned() + &id.to_string()).see_other()) Ok(web::Redirect::to("/item/".to_owned() + &id.to_string()).see_other())
} }

View file

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

View file

@ -4,15 +4,12 @@
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, web, Responder}; use actix_web::{error, get, web, Responder};
use maud::html; use maud::{html, Render};
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::database::{items::ItemPreview, ItemRepository};
use crate::frontend::templates::{ use crate::frontend::templates::{
self, self,
helpers::{ helpers::{Colour, PageAction, PageActionGroup, PageActionMethod},
Colour, ItemName, ItemPreview, ItemState, PageAction, PageActionGroup, PageActionMethod,
},
TemplateConfig, TemplateConfig,
}; };
@ -20,75 +17,15 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get); cfg.service(get);
} }
struct ItemListEntry {
id: Uuid,
name: ItemName,
class: Uuid,
class_name: String,
parents: Vec<ItemPreview>,
state: ItemState,
}
#[get("/items")] #[get("/items")]
async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> { async fn get(
let items = query!( item_repo: web::Data<ItemRepository>,
r#" user: Identity,
WITH RECURSIVE cte AS ( ) -> actix_web::Result<impl Responder> {
SELECT let items = item_repo
id, .list()
ARRAY[]::UUID[] AS parents, .await
ARRAY[]::VARCHAR[] AS parent_names, .map_err(error::ErrorInternalServerError)?;
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!",
item_states.state AS "state!: ItemState"
FROM cte
JOIN items
ON cte.id = items.id
JOIN item_classes
ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
ORDER BY items.created_at
"#
)
.map(|row| ItemListEntry {
id: row.id,
name: ItemName::new(row.name.as_ref(), &row.class_name),
class: row.class,
class_name: row.class_name,
parents: itertools::izip!(row.parents, row.parent_names, row.parent_class_names)
.map(|(id, name, class_name)| ItemPreview::from_parts(id, name.as_ref(), &class_name))
.collect(),
state: row.state,
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base( Ok(templates::base(
TemplateConfig { TemplateConfig {
@ -124,7 +61,13 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
td { (ItemPreview::new(item.id, item.name.clone().terse())) } td { (ItemPreview::new(item.id, item.name.clone().terse())) }
td { (item.state) } td { (item.state) }
td { a href={ "/item-class/" (item.class) } { (item.class_name) } } td { a href={ "/item-class/" (item.class) } { (item.class_name) } }
td { (templates::helpers::parents_breadcrumb(item.name, &item.parents, false)) } td {
(templates::helpers::parents_breadcrumb(
&item.name,
item.parents.iter().map(|parent| parent as &dyn Render).collect(),
false
))
}
} }
} }
} }

View file

@ -4,18 +4,15 @@
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, web, Responder}; use actix_web::{error, get, web, Responder};
use maud::html; use maud::{html, Render};
use serde_variant::to_variant_name; use serde_variant::to_variant_name;
use sqlx::{query, query_as, PgPool}; use time::OffsetDateTime;
use time::{Date, OffsetDateTime};
use uuid::Uuid; use uuid::Uuid;
use crate::database::{ItemEventRepository, ItemRepository, ItemStateRepository};
use crate::frontend::templates::{ use crate::frontend::templates::{
self, forms, self, forms,
helpers::{ helpers::{Colour, PageAction, PageActionGroup, PageActionMethod},
Colour, ItemEvent, ItemName, ItemPreview, ItemState, PageAction, PageActionGroup,
PageActionMethod,
},
TemplateConfig, TemplateConfig,
}; };
@ -23,159 +20,45 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get); cfg.service(get);
} }
struct ItemDetails {
id: Uuid,
short_id: i32,
name: ItemName,
class: Uuid,
class_name: String,
original_packaging: Option<ItemPreview>,
description: String,
state: ItemState,
}
struct ItemEventDetails {
id: i32,
date: Date,
event: ItemEvent,
description: String,
}
#[get("/item/{id}")] #[get("/item/{id}")]
async fn get( 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>, path: web::Path<Uuid>,
user: Identity, user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
let item = query!( let item = item_repo
r#"SELECT .details(id)
items.id, .await
items.short_id, .map_err(error::ErrorInternalServerError)?;
items.name,
items.class,
item_classes.name AS "class_name",
items.original_packaging,
op.name AS "original_packaging_name?",
op_class.name AS "original_packaging_class_name?",
op_state.state AS "original_packaging_state: ItemState",
items.description,
item_states.state AS "state!: ItemState"
FROM items
JOIN item_classes
ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
LEFT JOIN items AS "op"
ON items.original_packaging = op.id
LEFT JOIN item_classes AS "op_class"
ON op.class = op_class.id
LEFT JOIN item_states AS "op_state"
ON op.id = op_state.item
WHERE items.id = $1"#,
id
)
.map(|row| ItemDetails {
id: row.id,
short_id: row.short_id,
name: ItemName::new(row.name.as_ref(), &row.class_name),
class: row.class,
class_name: row.class_name,
original_packaging: row.original_packaging.map(|id| {
ItemPreview::from_parts(
id,
row.original_packaging_name.as_ref(),
&row.original_packaging_class_name.unwrap(),
)
.with_state(row.original_packaging_state.unwrap())
}),
description: row.description,
state: row.state,
})
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let events = query_as!( let events = item_event_repo
ItemEventDetails, .for_item(id)
r#"SELECT id, date, event AS "event: ItemEvent", description FROM item_events WHERE item = $1"#, .await
id .map_err(error::ErrorInternalServerError)?;
)
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let possible_events = query!( let possible_events = item_state_repo
r#"SELECT .possible_events(item.state)
event AS "event: ItemEvent", .await
next AS "next: ItemState" .map_err(error::ErrorInternalServerError)?;
FROM item_events_transitions
WHERE state = $1"#,
item.state as ItemState
)
.map(|row| (row.event, row.next))
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let parents = query!( let parents = item_repo
r#"SELECT items.id, items.name, item_classes.name AS "class_name" .parents(id)
FROM items .await
JOIN unnest((SELECT parents FROM item_tree WHERE id = $1)) .map_err(error::ErrorInternalServerError)?;
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 children = query!( let children = item_repo
r#"SELECT .children(id)
items.id, .await
items.name, .map_err(error::ErrorInternalServerError)?;
item_classes.name AS "class_name",
item_states.state AS "state!: ItemState"
FROM items
JOIN item_classes
ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
WHERE items.parent = $1"#,
id
)
.map(|row| {
ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state)
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let original_packaging_of = query!( let original_packaging_of = item_repo
r#"SELECT .original_packaging_of(id)
items.id, .await
items.name, .map_err(error::ErrorInternalServerError)?;
item_classes.name AS "class_name",
item_states.state AS "state!: ItemState"
FROM items
JOIN item_classes
ON items.class = item_classes.id
JOIN item_states
ON items.id = item_states.item
WHERE items.original_packaging = $1"#,
id
)
.map(|row| {
ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state)
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = item.name.to_string(); let mut title = item.name.to_string();
title.push_str(" Item Details"); title.push_str(" Item Details");
@ -238,7 +121,13 @@ async fn get(
} }
tr { tr {
th { "Parents" } th { "Parents" }
td { (templates::helpers::parents_breadcrumb(item.name, &parents, true)) } td {
(templates::helpers::parents_breadcrumb(
&item.name,
parents.iter().map(|parent| parent as &dyn Render).collect(),
true
))
}
} }
tr { tr {
th { "Original Packaging" } th { "Original Packaging" }

View file

@ -7,37 +7,25 @@ use std::fmt::Display;
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder}; use actix_web::{error, get, post, web, Responder};
use maud::html; 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) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post); 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")] #[get("/item-classes/add")]
async fn get( async fn get(
pool: web::Data<PgPool>, item_class_repo: web::Data<ItemClassRepository>,
form: web::Query<NewItemClassFormPrefilled>, form: web::Query<ItemClassAddFormPrefilled>,
user: Identity, user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let datalist_item_classes = datalist::item_classes(&pool) let datalist_item_classes = item_class_repo
.datalist()
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
@ -86,23 +74,16 @@ async fn get(
#[post("/item-classes/add")] #[post("/item-classes/add")]
async fn post( async fn post(
data: web::Form<NewItemClassForm>, data: web::Form<ItemClassAddForm>,
pool: web::Data<PgPool>, item_class_repo: web::Data<ItemClassRepository>,
_user: Identity, _user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let data = data.into_inner(); let data = data.into_inner();
let id = query_scalar!( let id = item_class_repo
"INSERT INTO item_classes (name, parent, description) .add(data)
VALUES ($1, $2, $3) .await
RETURNING id", .map_err(error::ErrorInternalServerError)?;
data.name,
data.parent,
data.description
)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other()) Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other())
} }

View file

@ -4,23 +4,24 @@
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, post, web, Responder}; use actix_web::{error, post, web, Responder};
use sqlx::{query, PgPool};
use uuid::Uuid; use uuid::Uuid;
use crate::database::ItemClassRepository;
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(post); cfg.service(post);
} }
#[post("/item-class/{id}/delete")] #[post("/item-class/{id}/delete")]
async fn post( async fn post(
pool: web::Data<PgPool>, item_class_repo: web::Data<ItemClassRepository>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
_user: Identity, _user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
query!("DELETE FROM item_classes WHERE id = $1", id) item_class_repo
.execute(pool.as_ref()) .delete(id)
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;

View file

@ -7,41 +7,31 @@ use std::fmt::Display;
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder}; use actix_web::{error, get, post, web, Responder};
use maud::html; use maud::html;
use serde::Deserialize;
use sqlx::{query, query_as, PgPool};
use uuid::Uuid; 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) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post); cfg.service(get).service(post);
} }
#[derive(Deserialize)]
struct ItemClassEditForm {
name: String,
parent: Option<Uuid>,
description: String,
}
#[get("/item-class/{id}/edit")] #[get("/item-class/{id}/edit")]
async fn get( async fn get(
pool: web::Data<PgPool>, item_class_repo: web::Data<ItemClassRepository>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
user: Identity, user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
let form = query_as!( let form = item_class_repo
ItemClassEditForm, .edit_form(id)
"SELECT name, parent, description FROM item_classes WHERE id = $1", .await
id .map_err(error::ErrorInternalServerError)?;
)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_item_classes = datalist::item_classes(&pool) let datalist_item_classes = item_class_repo
.datalist()
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
@ -102,25 +92,17 @@ async fn get(
#[post("/item-class/{id}/edit")] #[post("/item-class/{id}/edit")]
async fn post( async fn post(
pool: web::Data<PgPool>, item_class_repo: web::Data<ItemClassRepository>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
data: web::Form<ItemClassEditForm>, data: web::Form<ItemClassEditForm>,
_user: Identity, _user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
query!( item_class_repo
"UPDATE item_classes .edit(id, &data)
SET name = $2, parent = $3, description = $4 .await
WHERE id = $1", .map_err(error::ErrorInternalServerError)?;
id,
data.name,
data.parent,
data.description
)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other()) Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other())
} }

View file

@ -5,12 +5,11 @@
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, web, Responder}; use actix_web::{error, get, web, Responder};
use maud::html; use maud::html;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::database::ItemClassRepository;
use crate::frontend::templates::{ use crate::frontend::templates::{
self, self,
helpers::{Colour, ItemClassPreview, PageAction, PageActionGroup, PageActionMethod}, helpers::{Colour, PageAction, PageActionGroup, PageActionMethod},
TemplateConfig, TemplateConfig,
}; };
@ -18,32 +17,15 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get); cfg.service(get);
} }
struct ItemClassListEntry {
id: Uuid,
name: String,
parent: Option<ItemClassPreview>,
}
#[get("/item-classes")] #[get("/item-classes")]
async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> { async fn get(
let item_classes = query!( item_class_repo: web::Data<ItemClassRepository>,
r#"SELECT class.id, class.name, class.parent, parent.name AS "parent_name?" user: Identity,
FROM item_classes AS "class" ) -> actix_web::Result<impl Responder> {
LEFT JOIN item_classes AS "parent" let item_classes = item_class_repo
ON class.parent = parent.id .tree()
ORDER BY class.created_at .await
"# .map_err(error::ErrorInternalServerError)?;
)
.map(|row| ItemClassListEntry {
id: row.id,
name: row.name,
parent: row
.parent
.map(|id| ItemClassPreview::new(id, row.parent_name.unwrap())),
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base( Ok(templates::base(
TemplateConfig { TemplateConfig {
@ -64,26 +46,9 @@ async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl
..Default::default() ..Default::default()
}, },
html! { html! {
table .table { ul {
thead { @for item_class in item_classes {
tr { (item_class)
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 {
"-"
}
}
}
}
} }
} }
}, },

View file

@ -4,16 +4,13 @@
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, web, Responder}; use actix_web::{error, get, web, Responder};
use maud::html; use maud::{html, Render};
use sqlx::{query, query_as, PgPool};
use uuid::Uuid; use uuid::Uuid;
use crate::database::ItemClassRepository;
use crate::frontend::templates::{ use crate::frontend::templates::{
self, self,
helpers::{ helpers::{Colour, PageAction, PageActionGroup, PageActionMethod},
Colour, ItemClassPreview, ItemPreview, ItemState, PageAction, PageActionGroup,
PageActionMethod,
},
TemplateConfig, TemplateConfig,
}; };
@ -21,122 +18,76 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get); cfg.service(get);
} }
struct ItemClassDetails {
id: Uuid,
name: String,
description: String,
parent: Option<ItemClassPreview>,
}
#[get("/item-class/{id}")] #[get("/item-class/{id}")]
async fn get( async fn get(
pool: web::Data<PgPool>, item_class_repo: web::Data<ItemClassRepository>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
user: Identity, user: Identity,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
let item_class = query!( let item_class = item_class_repo
r#"SELECT .details(id)
class.id, .await
class.name, .map_err(error::ErrorInternalServerError)?;
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 children = query_as!( let parents = item_class_repo
ItemClassPreview, .parents(id)
"SELECT id, name FROM item_classes WHERE parent = $1", .await
id .map_err(error::ErrorInternalServerError)?;
)
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let items = query!( let children = item_class_repo
r#"SELECT .children(id)
items.id, .await
items.name, .map_err(error::ErrorInternalServerError)?;
item_classes.name AS "class_name",
item_states.state AS "state!: ItemState" let items = item_class_repo
FROM items .items(id)
JOIN item_classes .await
ON items.class = item_classes.id .map_err(error::ErrorInternalServerError)?;
JOIN item_states
ON items.id = item_states.item
WHERE items.class = $1"#,
id
)
.map(|row| {
ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state)
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = item_class.name.clone(); let mut title = item_class.name.clone();
title.push_str(" Item Details"); 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( Ok(templates::base(
TemplateConfig { TemplateConfig {
path: &format!("/item-class/{}", item_class.id), path: &format!("/item-class/{}", item_class.id),
title: Some(&title), title: Some(&title),
page_title: Some(Box::new(item_class.name.clone())), 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), user: Some(user),
..Default::default() ..Default::default()
}, },
@ -150,10 +101,14 @@ async fn get(
th { "Name" } th { "Name" }
td { (item_class.name) } td { (item_class.name) }
} }
@if let Some(parent) = item_class.parent { tr {
tr { th { "Parents" }
th { "Parent" } td {
td { (parent) } (templates::helpers::parents_breadcrumb(
&item_class.name,
parents.iter().map(|parent| parent as &dyn Render).collect(),
true
))
} }
} }
tr { tr {

View file

@ -5,19 +5,14 @@
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{error, get, web, Responder}; use actix_web::{error, get, web, Responder};
use serde::Deserialize; use serde::Deserialize;
use sqlx::{query, query_scalar, PgPool}; use sqlx::PgPool;
use uuid::Uuid;
use crate::database::search::{search_id, Entity};
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get); cfg.service(get);
} }
#[derive(Deserialize)]
pub enum EntityType {
Item,
ItemClass,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct JumpData { struct JumpData {
id: String, id: String,
@ -29,45 +24,18 @@ async fn get(
data: web::Query<JumpData>, data: web::Query<JumpData>,
_user: Identity, // this endpoint leaks information about the existence of items _user: Identity, // this endpoint leaks information about the existence of items
) -> Result<impl Responder, error::Error> { ) -> 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) { let entity = search_id(&pool, &data.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())
.await .await
.map_err(error::ErrorInternalServerError)? .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
};
if let Some(prefix) = entity_type.map(|entity_type| match entity_type { if let Some(entity) = entity {
EntityType::Item => "item", Ok(web::Redirect::to(match entity {
EntityType::ItemClass => "item-class", Entity::Item(id) => format!("/item/{id}"),
}) { Entity::ItemClass(id) => format!("/item-class/{id}"),
Ok(web::Redirect::to(format!("/{prefix}/{id}")).see_other()) })
.see_other())
} else { } else {
Ok(web::Redirect::to(format!( Ok(web::Redirect::to(format!(
"/items/add?{}", "/items/add?{}",

View file

@ -7,11 +7,10 @@ use actix_web::{error, get, post, web, Responder};
use maud::html; use maud::html;
use serde::Deserialize; use serde::Deserialize;
use serde_variant::to_variant_name; use serde_variant::to_variant_name;
use sqlx::{query, PgPool};
use uuid::Uuid; use uuid::Uuid;
use super::templates::{self, datalist, TemplateConfig}; use super::templates::{self, TemplateConfig};
use crate::frontend::templates::helpers::ItemPreview; use crate::database::ItemRepository;
use crate::label::{Label, LabelPreset}; use crate::label::{Label, LabelPreset};
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
@ -26,7 +25,10 @@ struct GenerateParams {
preset: LabelPreset, 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 let ids = params
.ids .ids
.split(',') .split(',')
@ -35,43 +37,41 @@ async fn generate(pool: &PgPool, params: GenerateParams) -> actix_web::Result<im
.collect::<Result<Vec<Uuid>, uuid::Error>>() .collect::<Result<Vec<Uuid>, uuid::Error>>()
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
Label::for_items(pool, &ids, params.preset.clone().into()) Label::for_items(item_repo, &ids, params.preset.clone().into())
.await .await
.map_err(error::ErrorInternalServerError) .map_err(error::ErrorInternalServerError)
} }
#[post("/labels/generate")] #[post("/labels/generate")]
async fn generate_post( async fn generate_post(
pool: web::Data<PgPool>, item_repo: web::Data<ItemRepository>,
_user: Identity, _user: Identity,
params: web::Form<GenerateParams>, params: web::Form<GenerateParams>,
) -> impl Responder { ) -> impl Responder {
generate(&pool, params.into_inner()).await generate(&item_repo, params.into_inner()).await
} }
#[get("/labels/generate")] #[get("/labels/generate")]
async fn generate_get( async fn generate_get(
pool: web::Data<PgPool>, item_repo: web::Data<ItemRepository>,
_user: Identity, _user: Identity,
params: web::Query<GenerateParams>, params: web::Query<GenerateParams>,
) -> impl Responder { ) -> impl Responder {
generate(&pool, params.into_inner()).await generate(&item_repo, params.into_inner()).await
} }
#[get("/labels")] #[get("/labels")]
async fn form(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> { async fn form(
let items = query!( item_repo: web::Data<ItemRepository>,
r#"SELECT items.id, items.name, item_classes.name AS "class_name" user: Identity,
FROM items ) -> actix_web::Result<impl Responder> {
JOIN item_classes let items = item_repo
ON items.class = item_classes.id"# .previews()
) .await
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name)) .map_err(error::ErrorInternalServerError)?;
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_items = datalist::items(&pool) let datalist_items = item_repo
.datalist()
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;

488
src/frontend/licensing.rs Normal file
View 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),
])),
]))
);
}
}

View file

@ -7,7 +7,8 @@ mod item;
mod item_class; mod item_class;
mod jump; mod jump;
mod labels; mod labels;
mod templates; mod licensing;
pub mod templates;
use actix_identity::Identity; use actix_identity::Identity;
use actix_web::{get, web, Responder}; use actix_web::{get, web, Responder};
@ -19,7 +20,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(item::config) .configure(item::config)
.configure(item_class::config) .configure(item_class::config)
.configure(jump::config) .configure(jump::config)
.configure(labels::config); .configure(labels::config)
.configure(licensing::config);
} }
#[get("/")] #[get("/")]

View file

@ -3,14 +3,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{html, Markup, Render}; use maud::{html, Markup, Render};
use sqlx::{query, PgPool};
use super::helpers::ItemName;
pub struct Datalist { pub struct Datalist {
name: String, pub name: String,
options: Vec<DatalistOption>, pub options: Vec<DatalistOption>,
link_prefix: Option<String>, pub link_prefix: Option<String>,
} }
impl Datalist { impl Datalist {
@ -32,8 +29,8 @@ impl Render for Datalist {
} }
pub struct DatalistOption { pub struct DatalistOption {
value: String, pub value: String,
text: Box<dyn Render>, pub text: Box<dyn Render>,
} }
impl Render for DatalistOption { impl Render for DatalistOption {
@ -41,40 +38,3 @@ impl Render for DatalistOption {
html! { option value=(self.value) { (self.text) } } 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(),
})
}

View file

@ -2,12 +2,12 @@
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use std::fmt::{self, Display}; use std::fmt;
use maud::{html, Markup, PreEscaped, Render};
use uuid::Uuid;
use crate::label::LabelPreset; use crate::label::LabelPreset;
use maud::{html, Markup, PreEscaped, Render};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub enum Css<'a> { pub enum Css<'a> {
File(&'a str), File(&'a str),
@ -88,113 +88,6 @@ impl Colour {
} }
} }
#[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,
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 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)
}
}
}
}
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 enum PageActionMethod { pub enum PageActionMethod {
Get, Get,
Post, Post,
@ -276,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; const LIMIT: usize = 3;
html! { html! {
@ -284,10 +177,10 @@ pub fn parents_breadcrumb(name: ItemName, parents: &[ItemPreview], full: bool) -
@if !full && parents.len() > LIMIT { @if !full && parents.len() > LIMIT {
li .breadcrumb-item { "" } li .breadcrumb-item { "" }
} }
@let parents: Box<dyn Iterator<Item = &ItemPreview>> = if full { @let parents: Box<dyn Iterator<Item = &dyn Render>> = if full {
Box::new(parents.iter()) Box::new(parents.into_iter())
} else { } else {
Box::new(parents.iter().rev().take(LIMIT).rev()) Box::new(parents.into_iter().rev().take(LIMIT).rev())
}; };
@for parent in parents { @for parent in parents {
li .breadcrumb-item { li .breadcrumb-item {
@ -298,77 +191,3 @@ pub fn parents_breadcrumb(name: ItemName, parents: &[ItemPreview], full: bool) -
} }
} }
} }
// TODO: Is this module the right place for ItemState and ItemEvent?
#[derive(Clone, Copy, Debug, sqlx::Type)]
#[sqlx(rename_all = "snake_case", type_name = "item_state")]
pub enum ItemState {
Borrowed,
Inactive,
Loaned,
Owned,
}
impl ItemState {
pub fn colour(&self) -> Colour {
match self {
ItemState::Borrowed => Colour::Warning,
ItemState::Inactive => Colour::Secondary,
ItemState::Loaned => Colour::Danger,
ItemState::Owned => Colour::Primary,
}
}
}
impl Render for ItemState {
fn render(&self) -> Markup {
html! {
span .badge.(self.colour().text_bg()) {
@match self {
ItemState::Borrowed => "borrowed",
ItemState::Inactive => "inactive",
ItemState::Loaned => "loaned",
ItemState::Owned => "owned",
}
}
}
}
}
#[derive(Debug, Deserialize, Serialize, sqlx::Type)]
#[sqlx(rename_all = "snake_case", type_name = "item_event")]
#[serde(rename_all = "snake_case")]
pub enum ItemEvent {
Acquire,
Borrow,
Buy,
Dispose,
Gift,
Loan,
Lose,
RecieveGift,
ReturnBorrowed,
ReturnLoaned,
Sell,
Use,
}
impl fmt::Display for ItemEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ItemEvent::Acquire => write!(f, "acquire"),
ItemEvent::Borrow => write!(f, "borrow"),
ItemEvent::Buy => write!(f, "buy"),
ItemEvent::Dispose => write!(f, "dispose"),
ItemEvent::Gift => write!(f, "gift"),
ItemEvent::Loan => write!(f, "loan"),
ItemEvent::Lose => write!(f, "lose"),
ItemEvent::RecieveGift => write!(f, "recieve gift"),
ItemEvent::ReturnBorrowed => write!(f, "return borrowed"),
ItemEvent::ReturnLoaned => write!(f, "return loaned"),
ItemEvent::Sell => write!(f, "sell"),
ItemEvent::Use => write!(f, "use"),
}
}
}

View file

@ -5,6 +5,7 @@
pub mod datalist; pub mod datalist;
pub mod forms; pub mod forms;
pub mod helpers; pub mod helpers;
mod render;
use actix_identity::Identity; use actix_identity::Identity;
use maud::{html, Markup, Render, DOCTYPE}; use maud::{html, Markup, Render, DOCTYPE};
@ -58,7 +59,13 @@ fn footer() -> Markup {
html! { html! {
footer .mt-auto.py-3.bg-body-tertiary.text-body-tertiary { footer .mt-auto.py-3.bg-body-tertiary.text-body-tertiary {
div .container { 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" }
")"
}
} }
} }
} }
@ -123,7 +130,7 @@ pub fn base(config: TemplateConfig, content: Markup) -> Markup {
(page_action) (page_action)
} }
} }
div .d-flex.align-items-center.gap-1 { div {
@if let Some(ref page_title) = config.page_title { @if let Some(ref page_title) = config.page_title {
h2 { h2 {
(page_title) (page_title)

View 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)
}
}
}
}
}
}
}

View file

@ -12,12 +12,13 @@ use barcode::{encode_code128, encode_data_matrix};
use pdf::{IndirectFontRef, PdfLayerReference}; use pdf::{IndirectFontRef, PdfLayerReference};
use printpdf as pdf; use printpdf as pdf;
use printpdf::{ImageTransform, Mm, PdfDocument, PdfDocumentReference, Pt, Px}; use printpdf::{ImageTransform, Mm, PdfDocument, PdfDocumentReference, Pt, Px};
use sqlx::{query_as, PgPool};
use thiserror::Error; use thiserror::Error;
use uuid::Uuid; use uuid::Uuid;
pub use preset::LabelPreset; pub use preset::LabelPreset;
use crate::database::ItemRepository;
const FONT: Cursor<&[u8]> = Cursor::new(include_bytes!( const FONT: Cursor<&[u8]> = Cursor::new(include_bytes!(
"../../assets/fonts/IosevkaLi7y-Regular.subset.ttf" "../../assets/fonts/IosevkaLi7y-Regular.subset.ttf"
)); ));
@ -32,6 +33,8 @@ pub enum Error {
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("PDF error: {0}")] #[error("PDF error: {0}")]
PrintPdf(#[from] printpdf::Error), 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)")] #[error("data is incomplete ({0} not given in data, but required in config)")]
DataIncomplete(String), DataIncomplete(String),
@ -168,7 +171,7 @@ impl TextConfig {
} }
} }
#[derive(Debug)] #[derive(Debug, PartialEq)]
pub struct LabelPage { pub struct LabelPage {
pub id: Option<Uuid>, pub id: Option<Uuid>,
pub short_id: Option<String>, pub short_id: Option<String>,
@ -247,17 +250,13 @@ impl Label {
Ok(doc.ok_or(Error::NoPages)?.save_to_bytes()?) 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 { Ok(Label {
pages: query_as!( pages: item_repo.label_pages(ids).await?,
LabelPage,
r#"SELECT id AS "id?", to_char(short_id, '000000') AS "short_id?"
FROM items
WHERE id = ANY ($1)"#,
ids
)
.fetch_all(pool)
.await?,
config, config,
}) })
} }

View file

@ -3,8 +3,87 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
mod config; mod config;
pub mod database;
pub mod frontend; pub mod frontend;
pub mod label; pub mod label;
pub mod middleware; pub mod middleware;
#[cfg(test)]
mod test_utils;
pub use config::Config; 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(),
))
}

View file

@ -2,42 +2,20 @@
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::IdentityMiddleware; use actix_web::HttpServer;
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
use actix_web::middleware::ErrorHandlers;
use actix_web::{cookie::Key, http::StatusCode, web, App, HttpResponse, HttpServer};
use base64::prelude::{Engine as _, BASE64_STANDARD};
use clap::Parser; use clap::Parser;
use log::{info, warn}; use log::info;
use mime_guess::from_path;
use rust_embed::Embed;
use li7y::Config; use li7y::Config;
#[derive(Embed)]
#[folder = "static"]
struct Static;
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse(); let config = Config::parse();
// generate a secret key with head -c 64 /dev/urandom | base64 -w0 // This cant be included in app, because app gets called in a (non-async) closure
let secret_key = match config.secret_key { let pool = sqlx::Pool::<sqlx::postgres::Postgres>::connect(&config.database_url)
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()
}
};
let pool: sqlx::PgPool = sqlx::Pool::<sqlx::postgres::Postgres>::connect(&config.database_url)
.await .await
.expect("failed to connect to database"); .expect("failed to connect to database");
@ -51,35 +29,8 @@ async fn main() -> std::io::Result<()> {
info!("Starting on {address}:{port}"); info!("Starting on {address}:{port}");
HttpServer::new(move || { HttpServer::new(move || li7y::app(&config, &pool))
App::new() .bind((address, port))?
.app_data(web::Data::new(config.clone())) .run()
.app_data(web::Data::new(pool.clone())) .await
.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
} }

11
src/test_utils.rs Normal file
View 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
}

View file

@ -16,9 +16,8 @@
const datalistHint = (input, hint) => { const datalistHint = (input, hint) => {
const selected = input.list.querySelector(`option[value="${input.value}"]`); const selected = input.list.querySelector(`option[value="${input.value}"]`);
if (selected === null) hint.innerHTML = ""
hint.innerText = "" if (selected !== null) {
else {
const linkPrefix = input.list.dataset.linkPrefix; const linkPrefix = input.list.dataset.linkPrefix;
if (linkPrefix === undefined) { if (linkPrefix === undefined) {
hint.innerHTML = selected.innerHTML hint.innerHTML = selected.innerHTML
@ -86,7 +85,32 @@
}) })
} }
document.getElementById("add-event-modal").addEventListener("show.bs.modal", e => { const addEventModal = document.getElementById("add-event-modal")
document.getElementById("event").value = e.relatedTarget.dataset.eventType 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
View 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
View 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
View 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
View 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
View 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 &lt;simon@sbruder.de&gt;<"));
assert!(body.contains(">RustCrypto Developers<"));
// license links (only partial, as I dont 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<"##));
}