Compare commits

...

11 commits

Author SHA1 Message Date
Simon Bruder a72d5b40e2
CI: Only gate OCI image push on branch
All checks were successful
/ build (push) Successful in 3m31s
2024-07-24 00:07:56 +02:00
Simon Bruder 1f6298af4a
Remove home link from navbar
There is nothing there and it can also be reached by clicking on the
branding.
2024-07-21 23:46:15 +02:00
Simon Bruder 5b3dd34312
Move away from models and manage subpackage
This architecture was started when the project still used Diesel.
Now that it uses SQLx, less things are done in Rust and more are done in
SQL. This commit now moves more of the query logic into SQL, which
should lead to more efficient queries and less moving data around.
2024-07-21 23:43:53 +02:00
Simon Bruder b7adf03dcc
Decrease visibility of NewItem(Class)Form* 2024-07-21 18:28:19 +02:00
Simon Bruder f4202a1ed5
Split frontend files 2024-07-21 15:57:51 +02:00
Simon Bruder d6a0f0a9ff
Do not use Item in generate_labels 2024-07-21 15:29:30 +02:00
Simon Bruder bb011cf84a
Use ItemPreview for parent breadcrumbs 2024-07-21 15:26:39 +02:00
Simon Bruder 5563cc1c9a
Add helper for item preview
Its API currently seems not that optimised, but in the future I want to
decrease the depdendencies on Item and ItemClass, so this is a step in
that direction.
2024-07-21 15:18:09 +02:00
Simon Bruder 82c7533b4c
Refactor ItemName::new to take strings directly
This loosens the dependency on always querying full Items and
ItemClasses.
2024-07-21 15:09:57 +02:00
Simon Bruder f21f8dfa5e
Make InputGroup value a reference
It is somewhat cumbersome to manually cast the value to &dyn Display
when mapping on an Option, but I guess it is more efficient.
2024-07-21 15:03:29 +02:00
Simon Bruder c8bc885919
Remove API
I didn’t really use it apart from very early testing and I don’t
anticipate it getting much use. It might be reintroduced in the future.
2024-07-20 23:09:22 +02:00
72 changed files with 2064 additions and 2546 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,56 @@
{
"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 || items.name,\n cte.parent_class_names || item_classes.name\n FROM items\n JOIN cte\n ON items.parent = cte.id\n JOIN item_classes\n ON items.class = item_classes.id\n )\n SELECT\n cte.id AS \"id!\",\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n cte.parents AS \"parents!\",\n cte.parent_names AS \"parent_names!: Vec<Option<String>>\",\n cte.parent_class_names AS \"parent_class_names!\"\n FROM cte\n JOIN items\n ON cte.id = items.id\n JOIN item_classes\n ON items.class = item_classes.id\n ORDER BY items.created_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "class_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "parents!",
"type_info": "UuidArray"
},
{
"ordinal": 5,
"name": "parent_names!: Vec<Option<String>>",
"type_info": "VarcharArray"
},
{
"ordinal": 6,
"name": "parent_class_names!",
"type_info": "VarcharArray"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
true,
false,
false,
null,
null,
null
]
},
"hash": "5dac9aa215abdc07cda1f4d46db4bcafec45b6264efcb8a175580a71e48c7421"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN unnest((SELECT parents FROM item_tree WHERE id = $1))\n WITH ORDINALITY AS parents(id, n)\n ON items.id = parents.id\n JOIN item_classes\n ON items.class = item_classes.id\n ORDER BY parents.n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "class_name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "ff39480d9395b357d71e9af3eb37fa308f4df6a0ca6442fa7f9bbda1e34ffbbe"
}

10
Cargo.lock generated
View file

@ -1113,6 +1113,15 @@ version = "1.70.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.11"
@ -1165,6 +1174,7 @@ dependencies = [
"enum-iterator",
"env_logger",
"futures-util",
"itertools",
"log",
"maud",
"mime",

View file

@ -19,6 +19,7 @@ datamatrix = "0.3.1"
enum-iterator = "2.1.0"
env_logger = "0.11.3"
futures-util = "0.3.30"
itertools = "0.13.0"
log = "0.4.21"
maud = { version = "0.26.0", features = ["actix-web"] }
mime = "0.3.17"

View file

@ -1,11 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod v1;
use actix_web::web;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/v1").configure(v1::config));
}

View file

@ -1,94 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_web::{delete, error, get, post, put, web, HttpResponse, Responder};
use sqlx::PgPool;
use uuid::Uuid;
use crate::manage;
use crate::models::*;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(add)
.service(list)
.service(show)
.service(update)
.service(delete)
.service(parents);
}
#[put("/item")]
async fn add(
pool: web::Data<PgPool>,
new_item: web::Json<NewItem>,
) -> actix_web::Result<impl Responder> {
let item = manage::item::add(&pool, new_item.into_inner())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(item))
}
#[get("/item")]
async fn list(pool: web::Data<PgPool>) -> actix_web::Result<impl Responder> {
let items = manage::item::get_all(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(items))
}
#[get("/item/{id}")]
async fn show(pool: web::Data<PgPool>, path: web::Path<Uuid>) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item = manage::item::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(item))
}
#[post("/item/{id}")]
async fn update(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
new_item: web::Json<NewItem>,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item = manage::item::update(&pool, id, new_item.into_inner())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(item))
}
#[delete("/item/{id}")]
async fn delete(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
manage::item::delete(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok())
}
#[get("/item/{id}/parents")]
async fn parents(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let parents = manage::item::get_parents(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(parents))
}

View file

@ -1,94 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_web::{delete, error, get, post, put, web, HttpResponse, Responder};
use sqlx::PgPool;
use uuid::Uuid;
use crate::manage;
use crate::models::*;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(add)
.service(list)
.service(show)
.service(items)
.service(update)
.service(delete);
}
#[put("/item-class")]
async fn add(
pool: web::Data<PgPool>,
new_item_class: web::Json<NewItemClass>,
) -> actix_web::Result<impl Responder> {
let item_class = manage::item_class::add(&pool, new_item_class.into_inner())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(item_class))
}
#[get("/item-class")]
async fn list(pool: web::Data<PgPool>) -> actix_web::Result<impl Responder> {
let item_classes = manage::item_class::get_all(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(item_classes))
}
#[get("/item-class/{id}")]
async fn show(pool: web::Data<PgPool>, path: web::Path<Uuid>) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_class = manage::item_class::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(item_class))
}
#[get("/item-class/{id}/items")]
async fn items(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let items = manage::item_class::items(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(items))
}
#[post("/item-class/{id}")]
async fn update(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
new_item_class: web::Json<NewItemClass>,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_class = manage::item_class::update(&pool, id, new_item_class.into_inner())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(item_class))
}
#[delete("/item-class/{id}")]
async fn delete(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
manage::item_class::delete(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok())
}

View file

@ -1,42 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_web::{error, get, web, Responder};
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use crate::label::{Label, LabelPreset};
use crate::manage;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(items);
}
#[derive(Debug, Deserialize)]
struct QueryParams {
// FIXME: serde_urlencoded does not support sequences
ids: String,
preset: LabelPreset,
}
#[get("/label/items")]
async fn items(
pool: web::Data<PgPool>,
params: web::Query<QueryParams>,
) -> actix_web::Result<impl Responder> {
let ids = params
.ids
.split(',')
.skip_while(|s| s.is_empty()) // to make the empty string parse as an empty iterator
.map(Uuid::try_parse)
.collect::<Result<Vec<Uuid>, uuid::Error>>()
.map_err(error::ErrorInternalServerError)?;
let items = manage::item::get_multiple(&pool, &ids)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(Label::for_items(&items, params.preset.clone().into()))
}

View file

@ -1,15 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
mod item;
mod item_class;
mod label;
use actix_web::web;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.configure(item::config)
.configure(item_class::config)
.configure(label::config);
}

View file

@ -1,550 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use actix_identity::Identity;
use actix_web::{error, get, post, web, HttpRequest, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use super::templates::helpers::{Colour, ItemName, PageAction, PageActionGroup, PageActionMethod};
use super::templates::{self, datalist, forms, TemplateConfig};
use crate::manage;
use crate::models::*;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(show)
.service(list)
.service(add_form)
.service(add_post)
.service(edit_form)
.service(edit)
.service(delete);
}
#[get("/item/{id}")]
async fn show(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item = manage::item::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let item_classes = manage::item_class::get_all_as_map(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let parents = manage::item::get_parents_details(&pool, item.id)
.await
.map_err(error::ErrorInternalServerError)?;
let children = manage::item::get_children(&pool, item.id)
.await
.map_err(error::ErrorInternalServerError)?;
let original_packaging = match item.original_packaging {
Some(id) => Some(
manage::item::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?,
),
None => None,
};
let original_packaging_of = manage::item::original_packaging_contents(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let item_class = item_classes.get(&item.class).unwrap();
let item_name = ItemName::new(&item, item_class);
let mut title = item_name.to_string();
title.push_str(" Item Details");
Ok(templates::base(
TemplateConfig {
path: &format!("/item/{}", item.id),
title: Some(&title),
page_title: Some(Box::new(item_name.clone())),
page_actions: vec![
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/items/add?parent={}", item.id),
name: "Add Child".to_string(),
},
colour: Colour::Success,
}),
PageActionGroup::generate_labels(&[&item]),
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/item/{}/edit", item.id),
name: "Edit".to_string(),
},
colour: Colour::Warning,
}),
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Post,
target: format!("/item/{}/delete", item.id),
name: "Delete".to_string(),
},
colour: Colour::Danger,
}),
],
user: Some(user),
..Default::default()
},
html! {
table .table {
tr {
th { "UUID" }
td { (item.id) }
}
tr {
th { "Short ID" }
td { (item.short_id) }
}
tr {
th { "Name" }
td { (item_name.clone().terse()) }
}
tr {
th { "Class" }
td { a href={ "/item-class/" (item.class) } { (item_class.name) } }
}
tr {
th { "Parents" }
td { (templates::helpers::parents_breadcrumb(&item, &item_class, &parents, &item_classes, true)) }
}
tr {
th { "Original Packaging" }
td {
@if let Some(original_packaging) = original_packaging {
a
href={ "/item/" (original_packaging.id) }
{ (ItemName::new(&original_packaging, &item_classes.get(&original_packaging.class).unwrap())) }
} @else {
"-"
}
}
}
tr {
th { "Description" }
td style="white-space: pre-wrap" { (item.description) }
}
}
@if !children.is_empty() {
div .d-flex.justify-content-between.mt-4 {
div {
h3 { "Direct Children (" (children.len()) ")" }
}
div {
(PageActionGroup::generate_labels(
&children.iter().collect::<Vec<&Item>>(),
))
}
}
ul {
@for child in children {
li {
a href={ "/item/" (child.id) } { (ItemName::new(&child, &item_classes.get(&child.class).unwrap())) }
}
}
}
}
@if !original_packaging_of.is_empty() {
h3 .mt-4 { "Original Packaging of" }
ul {
@for item in original_packaging_of {
li {
a href={ "/item/" (item.id) } { (ItemName::new(&item, &item_classes.get(&item.class).unwrap())) }
}
}
}
}
},
))
}
#[get("/items")]
async fn list(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> {
let item_list = manage::item::get_all(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let items = manage::item::get_all_as_map(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let item_classes = manage::item_class::get_all_as_map(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let item_tree = manage::item::get_all_parents(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
// TODO: remove clone (should be possible without it)
let item_parents: HashMap<Uuid, Vec<Item>> = item_tree
.iter()
.map(|(id, parent_ids)| {
(
*id,
parent_ids
.iter()
.map(|parent_id| items.get(parent_id).unwrap().clone())
.collect(),
)
})
.collect();
Ok(templates::base(
TemplateConfig {
path: "/items",
title: Some("Item List"),
page_title: Some(Box::new("Item List")),
page_actions: vec![
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: "/items/add".to_string(),
name: "Add".to_string(),
},
colour: Colour::Success,
}),
],
user: Some(user),
..Default::default()
},
html! {
table .table {
thead {
tr {
th { "Name" }
th { "Class" }
th { "Parents" }
}
}
tbody {
@for item in item_list {
@let class = item_classes.get(&item.class).unwrap();
tr {
td {
a href={ "/item/" (item.id) } {
(ItemName::new(&item, class).terse())
}
}
td { a href={ "/item-class/" (class.id) } { (class.name) } }
td { (templates::helpers::parents_breadcrumb(&item, &class, item_parents.get(&item.id).unwrap(), &item_classes, false)) }
}
}
}
}
},
))
}
fn default_quantity() -> usize {
1
}
#[derive(Debug, Deserialize)]
pub struct NewItemForm {
#[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(Debug, Deserialize)]
pub struct NewItemFormPrefilled {
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>,
}
#[get("/items/add")]
async fn add_form(
pool: web::Data<PgPool>,
form: web::Query<NewItemFormPrefilled>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let datalist_items = datalist::items(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_item_classes = datalist::item_classes(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
path: "/items/add",
title: Some("Add Item"),
page_title: Some(Box::new("Add Item")),
datalists: vec![&datalist_items, &datalist_item_classes],
user: Some(user),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
optional: true,
value: form.name.clone(),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "class",
title: "Class",
required: true,
value: form.class.map(|id| id.to_string()),
datalist: Some(&datalist_item_classes),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
optional: true,
value: form.parent.map(|id| id.to_string()),
datalist: Some(&datalist_items),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "original_packaging",
title: "Original Packaging",
optional: true,
disabled: true,
value: form.original_packaging.map(|id| id.to_string()),
datalist: Some(&datalist_items),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: form.description.clone(),
..Default::default()
})
div .input-group {
button .btn.btn-primary type="submit" { "Add" }
input .form-control.flex-grow-0.w-auto #quantity name="quantity" type="number" value=(form.quantity.unwrap_or(default_quantity())) min="1";
}
}
},
))
}
#[post("/items/add")]
async fn add_post(
req: HttpRequest,
data: web::Form<NewItemForm>,
pool: web::Data<PgPool>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let data = data.into_inner();
let new_item = NewItem {
name: data.name,
class: data.class,
parent: data.parent,
original_packaging: data.original_packaging,
description: data.description,
};
if data.quantity == 1 {
let item = manage::item::add(&pool, new_item)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(
web::Redirect::to("/item/".to_owned() + &item.id.to_string())
.see_other()
.respond_to(&req)
.map_into_boxed_body(),
)
} else {
let items = manage::item::add_multiple(&pool, new_item, data.quantity)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
path: "/items/add",
title: Some("Added Items"),
page_title: Some(Box::new("Added Items")),
page_actions: vec![PageActionGroup::generate_labels(
&items.iter().collect::<Vec<&Item>>(),
)],
user: Some(user),
..Default::default()
},
html! {
ul {
@for item in items {
li {
a href={ "/item/" (item.id) } { (item.id) }
}
}
}
a href="/items" { "Back to all items" }
},
)
.respond_to(&req)
.map_into_boxed_body())
}
}
#[get("/item/{id}/edit")]
async fn edit_form(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item = manage::item::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let item_class = manage::item_class::get(&pool, item.class)
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_items = datalist::items(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_item_classes = datalist::item_classes(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let item_name = ItemName::new(&item, &item_class);
let mut title = item_name.to_string();
title.push_str(" Edit Item");
Ok(templates::base(
TemplateConfig {
path: &format!("/item/{}/edit", item.id),
title: Some(&title),
page_title: Some(Box::new(item_name.clone())),
datalists: vec![&datalist_items, &datalist_item_classes],
user: Some(user),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "id",
title: "UUID",
required: true,
disabled: true,
value: Some(item.id.to_string()),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
optional: true,
disabled: item.name.is_none(),
value: item.name,
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "class",
title: "Class",
required: true,
value: Some(item.class.to_string()),
datalist: Some(&datalist_item_classes),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
optional: true,
value: item.parent.map(|id| id.to_string()),
disabled: item.parent.is_none(),
datalist: Some(&datalist_items),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "original_packaging",
title: "Original Packaging",
optional: true,
value: item.original_packaging.map(|id| id.to_string()),
disabled: item.original_packaging.is_none(),
datalist: Some(&datalist_items),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: Some(item.description),
..Default::default()
})
button .btn.btn-primary type="submit" { "Edit" }
}
},
))
}
#[post("/item/{id}/edit")]
async fn edit(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
data: web::Form<NewItem>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item = manage::item::update(&pool, id, data.into_inner())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item/".to_owned() + &item.id.to_string()).see_other())
}
#[post("/item/{id}/delete")]
async fn delete(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
manage::item::delete(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/items").see_other())
}

191
src/frontend/item/add.rs Normal file
View file

@ -0,0 +1,191 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::fmt::Display;
use actix_identity::Identity;
use actix_web::{error, get, post, web, HttpRequest, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::{query_scalar, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{self, datalist, forms, helpers::PageActionGroup, TemplateConfig};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post);
}
fn default_quantity() -> usize {
1
}
#[derive(Debug, Deserialize)]
struct NewItemForm {
#[serde(default = "default_quantity")]
quantity: usize,
name: Option<String>,
parent: Option<Uuid>,
class: Uuid,
original_packaging: Option<Uuid>,
description: String,
}
#[derive(Debug, Deserialize)]
struct NewItemFormPrefilled {
quantity: Option<usize>,
name: Option<String>,
parent: Option<Uuid>,
class: Option<Uuid>,
original_packaging: Option<Uuid>,
description: Option<String>,
}
#[get("/items/add")]
async fn get(
pool: web::Data<PgPool>,
form: web::Query<NewItemFormPrefilled>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let datalist_items = datalist::items(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_item_classes = datalist::item_classes(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
path: "/items/add",
title: Some("Add Item"),
page_title: Some(Box::new("Add Item")),
datalists: vec![&datalist_items, &datalist_item_classes],
user: Some(user),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
optional: true,
value: form.name.as_ref().map(|s| s as &dyn Display),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "class",
title: "Class",
required: true,
value: form.class.as_ref().map(|id| id as &dyn Display),
datalist: Some(&datalist_item_classes),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
optional: true,
value: form.parent.as_ref().map(|id| id as &dyn Display),
datalist: Some(&datalist_items),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "original_packaging",
title: "Original Packaging",
optional: true,
disabled: true,
value: form.original_packaging.as_ref().map(|id| id as &dyn Display),
datalist: Some(&datalist_items),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: form.description.as_ref().map(|s| s as &dyn Display),
..Default::default()
})
div .input-group {
button .btn.btn-primary type="submit" { "Add" }
input .form-control.flex-grow-0.w-auto #quantity name="quantity" type="number" value=(form.quantity.unwrap_or(default_quantity())) min="1";
}
}
},
))
}
#[post("/items/add")]
async fn post(
req: HttpRequest,
data: web::Form<NewItemForm>,
pool: web::Data<PgPool>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let data = data.into_inner();
if data.quantity == 1 {
query_scalar!(
"INSERT INTO items (name, parent, class, original_packaging, description)
VALUES ($1, $2, $3, $4, $5)
RETURNING id",
data.name,
data.parent,
data.class,
data.original_packaging,
data.description
)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)
.map(|id| {
web::Redirect::to("/item/".to_owned() + &id.to_string())
.see_other()
.respond_to(&req)
.map_into_boxed_body()
})
} 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())
.await
.map_err(error::ErrorInternalServerError)
.map(|ids| {
templates::base(
TemplateConfig {
path: "/items/add",
title: Some("Added Items"),
page_title: Some(Box::new("Added Items")),
page_actions: vec![PageActionGroup::generate_labels(&ids)],
user: Some(user),
..Default::default()
},
html! {
ul {
@for id in &ids {
li {
a href={ "/item/" (id) } { (id) }
}
}
}
a href="/items" { "Back to all items" }
},
)
.respond_to(&req)
.map_into_boxed_body()
})
}
}

View file

@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::Identity;
use actix_web::{error, post, web, Responder};
use sqlx::{query, PgPool};
use uuid::Uuid;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(post);
}
#[post("/item/{id}/delete")]
async fn post(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
query!("DELETE FROM items WHERE id = $1", id)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/items").see_other())
}

175
src/frontend/item/edit.rs Normal file
View file

@ -0,0 +1,175 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::fmt::Display;
use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{self, datalist, forms, helpers::ItemName, TemplateConfig};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post);
}
#[derive(Deserialize)]
struct ItemEditForm {
name: Option<String>,
parent: Option<Uuid>,
class: Uuid,
original_packaging: Option<Uuid>,
description: String,
}
#[get("/item/{id}/edit")]
async fn get(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let (item_name, form) = query!(
r#"SELECT
items.name,
items.parent,
items.class,
item_classes.name AS "class_name",
items.original_packaging,
items.description
FROM items
JOIN item_classes
ON items.class = item_classes.id
WHERE items.id = $1"#,
id
)
.map(|row| {
(
ItemName::new(row.name.as_ref(), &row.class_name),
ItemEditForm {
name: row.name,
parent: row.parent,
class: row.class,
original_packaging: row.original_packaging,
description: row.description,
},
)
})
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_items = datalist::items(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_item_classes = datalist::item_classes(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = item_name.to_string();
title.push_str(" Edit Item");
Ok(templates::base(
TemplateConfig {
path: &format!("/item/{}/edit", id),
title: Some(&title),
page_title: Some(Box::new(item_name.clone())),
datalists: vec![&datalist_items, &datalist_item_classes],
user: Some(user),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "id",
title: "UUID",
required: true,
disabled: true,
value: Some(&id),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
optional: true,
disabled: form.name.is_none(),
value: form.name.as_ref().map(|s| s as &dyn Display),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "class",
title: "Class",
required: true,
value: Some(&form.class),
datalist: Some(&datalist_item_classes),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
optional: true,
value: form.parent.as_ref().map(|id| id as &dyn Display),
disabled: form.parent.is_none(),
datalist: Some(&datalist_items),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "original_packaging",
title: "Original Packaging",
optional: true,
value: form.original_packaging.as_ref().map(|id| id as &dyn Display),
disabled: form.original_packaging.is_none(),
datalist: Some(&datalist_items),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: Some(&form.description),
..Default::default()
})
button .btn.btn-primary type="submit" { "Edit" }
}
},
))
}
#[post("/item/{id}/edit")]
async fn post(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
data: web::Form<ItemEditForm>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
query!(
"UPDATE items
SET name = $2, parent = $3, class = $4, original_packaging = $5, description = $6
WHERE id = $1",
id,
data.name,
data.parent,
data.class,
data.original_packaging,
data.description
)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item/".to_owned() + &id.to_string()).see_other())
}

123
src/frontend/item/list.rs Normal file
View file

@ -0,0 +1,123 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use maud::html;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{
self,
helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod},
TemplateConfig,
};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
struct ItemListEntry {
id: Uuid,
name: ItemName,
class: Uuid,
class_name: String,
parents: Vec<ItemPreview>,
}
#[get("/items")]
async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> {
let items = query!(
r#"
WITH RECURSIVE cte AS (
SELECT
id,
ARRAY[]::UUID[] AS parents,
ARRAY[]::VARCHAR[] AS parent_names,
ARRAY[]::VARCHAR[] AS parent_class_names
FROM items
WHERE parent IS NULL
UNION
SELECT
items.id,
cte.parents || items.parent,
cte.parent_names || items.name,
cte.parent_class_names || item_classes.name
FROM items
JOIN cte
ON items.parent = cte.id
JOIN item_classes
ON items.class = item_classes.id
)
SELECT
cte.id AS "id!",
items.name,
items.class,
item_classes.name AS "class_name",
cte.parents AS "parents!",
cte.parent_names AS "parent_names!: Vec<Option<String>>",
cte.parent_class_names AS "parent_class_names!"
FROM cte
JOIN items
ON cte.id = items.id
JOIN item_classes
ON items.class = item_classes.id
ORDER BY items.created_at
"#
)
.map(|row| ItemListEntry {
id: row.id,
name: ItemName::new(row.name.as_ref(), &row.class_name),
class: row.class,
class_name: row.class_name,
parents: itertools::izip!(row.parents, row.parent_names, row.parent_class_names)
.map(|(id, name, class_name)| ItemPreview::from_parts(id, name.as_ref(), &class_name))
.collect(),
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
path: "/items",
title: Some("Item List"),
page_title: Some(Box::new("Item List")),
page_actions: vec![
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: "/items/add".to_string(),
name: "Add".to_string(),
},
colour: Colour::Success,
}),
],
user: Some(user),
..Default::default()
},
html! {
table .table {
thead {
tr {
th { "Name" }
th { "Class" }
th { "Parents" }
}
}
tbody {
@for item in items {
tr {
td { (ItemPreview::new(item.id, item.name.clone().terse())) }
td { a href={ "/item-class/" (item.class) } { (item.class_name) } }
td { (templates::helpers::parents_breadcrumb(item.name, &item.parents, false)) }
}
}
}
}
},
))
}

19
src/frontend/item/mod.rs Normal file
View file

@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
mod add;
mod delete;
mod edit;
mod list;
mod show;
use actix_web::web;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.configure(add::config)
.configure(delete::config)
.configure(edit::config)
.configure(list::config)
.configure(show::config);
}

227
src/frontend/item/show.rs Normal file
View file

@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use maud::html;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{
self,
helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod},
TemplateConfig,
};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
struct ItemDetails {
id: Uuid,
short_id: i32,
name: ItemName,
class: Uuid,
class_name: String,
original_packaging: Option<ItemPreview>,
description: String,
}
#[get("/item/{id}")]
async fn get(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item = query!(
r#"SELECT
items.id,
items.short_id,
items.name,
items.class,
item_classes.name AS "class_name",
items.original_packaging,
op.name AS "original_packaging_name?",
op_class.name AS "original_packaging_class_name?",
items.description
FROM items
JOIN item_classes
ON items.class = item_classes.id
LEFT JOIN items AS "op"
ON items.original_packaging = op.id
LEFT JOIN item_classes AS "op_class"
ON op.class = op_class.id
WHERE items.id = $1"#,
id
)
.map(|row| ItemDetails {
id: row.id,
short_id: row.short_id,
name: ItemName::new(row.name.as_ref(), &row.class_name),
class: row.class,
class_name: row.class_name,
original_packaging: row.original_packaging.map(|id| {
ItemPreview::from_parts(
id,
row.original_packaging_name.as_ref(),
&row.original_packaging_class_name.unwrap(),
)
}),
description: row.description,
})
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let parents = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name"
FROM items
JOIN unnest((SELECT parents FROM item_tree WHERE id = $1))
WITH ORDINALITY AS parents(id, n)
ON items.id = parents.id
JOIN item_classes
ON items.class = item_classes.id
ORDER BY parents.n"#,
id
)
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name))
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let children = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name"
FROM items
JOIN item_classes
ON items.class = item_classes.id
WHERE items.parent = $1"#,
id
)
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name))
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let original_packaging_of = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name"
FROM items
JOIN item_classes
ON items.class = item_classes.id
WHERE items.original_packaging = $1"#,
id
)
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name))
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = item.name.to_string();
title.push_str(" Item Details");
Ok(templates::base(
TemplateConfig {
path: &format!("/item/{}", item.id),
title: Some(&title),
page_title: Some(Box::new(item.name.clone())),
page_actions: vec![
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/items/add?parent={}", item.id),
name: "Add Child".to_string(),
},
colour: Colour::Success,
}),
PageActionGroup::generate_labels(&[item.id]),
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/item/{}/edit", item.id),
name: "Edit".to_string(),
},
colour: Colour::Warning,
}),
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Post,
target: format!("/item/{}/delete", item.id),
name: "Delete".to_string(),
},
colour: Colour::Danger,
}),
],
user: Some(user),
..Default::default()
},
html! {
table .table {
tr {
th { "UUID" }
td { (item.id) }
}
tr {
th { "Short ID" }
td { (item.short_id) }
}
tr {
th { "Name" }
td { (item.name.clone().terse()) }
}
tr {
th { "Class" }
td { a href={ "/item-class/" (item.class) } { (item.class_name) } }
}
tr {
th { "Parents" }
td { (templates::helpers::parents_breadcrumb(item.name, &parents, true)) }
}
tr {
th { "Original Packaging" }
td {
@if let Some(original_packaging) = item.original_packaging {
(original_packaging)
} @else {
"-"
}
}
}
tr {
th { "Description" }
td style="white-space: pre-wrap" { (item.description) }
}
}
@if !children.is_empty() {
div .d-flex.justify-content-between.mt-4 {
div {
h3 { "Direct Children (" (children.len()) ")" }
}
div {
(PageActionGroup::generate_labels(
&children.iter().map(|ip| ip.id).collect::<Vec<Uuid>>(),
))
}
}
ul {
@for child in children {
li { (child) }
}
}
}
@if !original_packaging_of.is_empty() {
h3 .mt-4 { "Original Packaging of" }
ul {
@for item in original_packaging_of {
li { (item) }
}
}
}
},
))
}

View file

@ -1,408 +0,0 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use super::templates::helpers::{Colour, ItemName, PageAction, PageActionGroup, PageActionMethod};
use super::templates::{self, datalist, forms, TemplateConfig};
use crate::manage;
use crate::models::*;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(show)
.service(list)
.service(add_form)
.service(add)
.service(edit_form)
.service(edit)
.service(delete);
}
#[get("/item-class/{id}")]
async fn show(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_class = manage::item_class::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
// TODO: Once async closures are stable, use map_or on item_class.parent instead
let parent = match item_class.parent {
Some(id) => manage::item_class::get(&pool, id)
.await
.map(Some)
.map_err(error::ErrorInternalServerError)?,
None => None,
};
let children = manage::item_class::children(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let items = manage::item_class::items(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = item_class.name.clone();
title.push_str(" Item Details");
let mut page_actions = vec![
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/items/add?class={}", item_class.id),
name: "Add Item".to_string(),
},
colour: Colour::Success,
}),
];
if item_class.parent.is_none() {
page_actions.push(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/item-classes/add?parent={}", item_class.id),
name: "Add Child".to_string(),
},
colour: Colour::Primary,
});
}
page_actions.push(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/item-class/{}/edit", item_class.id),
name: "Edit".to_string(),
},
colour: Colour::Warning,
});
page_actions.push(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Post,
target: format!("/item-class/{}/delete", item_class.id),
name: "Delete".to_string(),
},
colour: Colour::Danger,
});
Ok(templates::base(
TemplateConfig {
path: &format!("/item-class/{}", item_class.id),
title: Some(&title),
page_title: Some(Box::new(item_class.name.clone())),
page_actions,
user: Some(user),
..Default::default()
},
html! {
table .table {
tr {
th { "UUID" }
td { (item_class.id) }
}
tr {
th { "Name" }
td { (item_class.name) }
}
@if let Some(parent) = parent {
tr {
th { "Parent" }
td { a href={ "/item-class/" (parent.id) } { (parent.name) } }
}
}
tr {
th { "Description" }
td style="white-space: pre-wrap" { (item_class.description) }
}
}
@if !children.is_empty() {
h3 .mt-4 { "Children (" (children.len()) ")" }
ul {
@for child in children {
li {
a href={ "/item-class/" (child.id) } { (child.name) }
}
}
}
}
@if !items.is_empty() {
div .d-flex.justify-content-between.mt-4 {
div {
h3 { "Items (" (items.len()) ")" }
}
div {
(PageActionGroup::generate_labels(
&items.iter().collect::<Vec<&Item>>(),
))
}
}
ul {
@for item in items {
li {
a href={ "/item/" (item.id) } { (ItemName::new(&item, &item_class).terse()) }
}
}
}
}
},
))
}
#[get("/item-classes")]
async fn list(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> {
let item_classes_ids = sqlx::query_scalar!("SELECT id FROM item_classes ORDER BY created_at")
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let item_classes = manage::item_class::get_all_as_map(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
path: "/item-classes",
title: Some("Item Class List"),
page_title: Some(Box::new("Item Class List")),
page_actions: vec![
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: "/item-classes/add".to_string(),
name: "Add".to_string(),
},
colour: Colour::Success,
}),
],
user: Some(user),
..Default::default()
},
html! {
table .table {
thead {
tr {
th { "Name" }
th { "Parents" }
}
}
tbody {
@for item_class in item_classes_ids {
@let item_class = item_classes.get(&item_class).unwrap();
tr {
td { a href={ "/item-class/" (item_class.id) } { (item_class.name) } }
td {
@if let Some(parent) = item_class.parent {
@let parent = item_classes.get(&parent).unwrap();
a href={ "/item-class/" (parent.id) } { (parent.name) }
} @else {
"-"
}
}
}
}
}
}
},
))
}
#[derive(Debug, Deserialize)]
pub struct NewItemClassForm {
pub name: String,
pub parent: Option<Uuid>,
pub description: String,
}
#[derive(Debug, Deserialize)]
pub struct NewItemClassFormPrefilled {
pub name: Option<String>,
pub parent: Option<Uuid>,
pub description: Option<String>,
}
#[get("/item-classes/add")]
async fn add_form(
pool: web::Data<PgPool>,
form: web::Query<NewItemClassFormPrefilled>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let datalist_item_classes = datalist::item_classes(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
path: "/items-classes/add",
title: Some("Add Item Class"),
page_title: Some(Box::new("Add Item Class")),
datalists: vec![&datalist_item_classes],
user: Some(user),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
required: true,
value: form.name.clone(),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
optional: true,
disabled: form.parent.is_none(),
value: form.parent.map(|id| id.to_string()),
datalist: Some(&datalist_item_classes),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: form.description.clone(),
..Default::default()
})
button .btn.btn-primary type="submit" { "Add" }
}
},
))
}
#[post("/item-classes/add")]
async fn add(
data: web::Form<NewItemClassForm>,
pool: web::Data<PgPool>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let data = data.into_inner();
let item = manage::item_class::add(
&pool,
NewItemClass {
name: data.name,
parent: data.parent,
description: data.description,
},
)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-class/".to_owned() + &item.id.to_string()).see_other())
}
#[get("/item-class/{id}/edit")]
async fn edit_form(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_class = manage::item_class::get(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_item_classes = datalist::item_classes(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = item_class.name.clone();
title.push_str(" Item Details");
Ok(templates::base(
TemplateConfig {
path: &format!("/items-class/{}/add", id),
title: Some(&title),
page_title: Some(Box::new(item_class.name.clone())),
datalists: vec![&datalist_item_classes],
user: Some(user),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "id",
title: "UUID",
disabled: true,
required: true,
value: Some(item_class.id.to_string()),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
required: true,
value: Some(item_class.name),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
optional: true,
disabled: item_class.parent.is_none(),
value: item_class.parent.map(|id| id.to_string()),
datalist: Some(&datalist_item_classes),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: Some(item_class.description),
..Default::default()
})
button .btn.btn-primary type="submit" { "Edit" }
}
},
))
}
#[post("/item-class/{id}/edit")]
async fn edit(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
data: web::Form<NewItemClass>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_class = manage::item_class::update(&pool, id, data.into_inner())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-class/".to_owned() + &item_class.id.to_string()).see_other())
}
#[post("/item-class/{id}/delete")]
async fn delete(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
manage::item_class::delete(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-classes").see_other())
}

View file

@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::fmt::Display;
use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::{query_scalar, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{self, datalist, forms, TemplateConfig};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post);
}
#[derive(Debug, Deserialize)]
struct NewItemClassForm {
name: String,
parent: Option<Uuid>,
description: String,
}
#[derive(Debug, Deserialize)]
struct NewItemClassFormPrefilled {
name: Option<String>,
parent: Option<Uuid>,
description: Option<String>,
}
#[get("/item-classes/add")]
async fn get(
pool: web::Data<PgPool>,
form: web::Query<NewItemClassFormPrefilled>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let datalist_item_classes = datalist::item_classes(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
path: "/items-classes/add",
title: Some("Add Item Class"),
page_title: Some(Box::new("Add Item Class")),
datalists: vec![&datalist_item_classes],
user: Some(user),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
required: true,
value: form.name.as_ref().map(|s| s as &dyn Display),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
optional: true,
disabled: form.parent.is_none(),
value: form.parent.as_ref().map(|id| id as &dyn Display),
datalist: Some(&datalist_item_classes),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: form.description.as_ref().map(|s| s as &dyn Display),
..Default::default()
})
button .btn.btn-primary type="submit" { "Add" }
}
},
))
}
#[post("/item-classes/add")]
async fn post(
data: web::Form<NewItemClassForm>,
pool: web::Data<PgPool>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let data = data.into_inner();
let id = query_scalar!(
"INSERT INTO item_classes (name, parent, description)
VALUES ($1, $2, $3)
RETURNING id",
data.name,
data.parent,
data.description
)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other())
}

View file

@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::Identity;
use actix_web::{error, post, web, Responder};
use sqlx::{query, PgPool};
use uuid::Uuid;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(post);
}
#[post("/item-class/{id}/delete")]
async fn post(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
query!("DELETE FROM item_classes WHERE id = $1", id)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-classes").see_other())
}

View file

@ -0,0 +1,126 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::fmt::Display;
use actix_identity::Identity;
use actix_web::{error, get, post, web, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::{query, query_as, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{self, datalist, forms, TemplateConfig};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get).service(post);
}
#[derive(Deserialize)]
struct ItemClassEditForm {
name: String,
parent: Option<Uuid>,
description: String,
}
#[get("/item-class/{id}/edit")]
async fn get(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let form = query_as!(
ItemClassEditForm,
"SELECT name, parent, description FROM item_classes WHERE id = $1",
id
)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let datalist_item_classes = datalist::item_classes(&pool)
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = form.name.clone();
title.push_str(" Item Details");
Ok(templates::base(
TemplateConfig {
path: &format!("/items-class/{}/add", id),
title: Some(&title),
page_title: Some(Box::new(form.name.clone())),
datalists: vec![&datalist_item_classes],
user: Some(user),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "id",
title: "UUID",
disabled: true,
required: true,
value: Some(&id),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
required: true,
value: Some(&form.name),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
optional: true,
disabled: form.parent.is_none(),
value: form.parent.as_ref().map(|id| id as &dyn Display),
datalist: Some(&datalist_item_classes),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Textarea,
name: "description",
title: "Description ",
value: Some(&form.description),
..Default::default()
})
button .btn.btn-primary type="submit" { "Edit" }
}
},
))
}
#[post("/item-class/{id}/edit")]
async fn post(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
data: web::Form<ItemClassEditForm>,
_user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
query!(
"UPDATE item_classes
SET name = $2, parent = $3, description = $4
WHERE id = $1",
id,
data.name,
data.parent,
data.description
)
.execute(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other())
}

View file

@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use maud::html;
use sqlx::{query, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{
self,
helpers::{Colour, ItemClassPreview, PageAction, PageActionGroup, PageActionMethod},
TemplateConfig,
};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
struct ItemClassListEntry {
id: Uuid,
name: String,
parent: Option<ItemClassPreview>,
}
#[get("/item-classes")]
async fn get(pool: web::Data<PgPool>, user: Identity) -> actix_web::Result<impl Responder> {
let item_classes = query!(
r#"SELECT class.id, class.name, class.parent, parent.name AS "parent_name?"
FROM item_classes AS "class"
LEFT JOIN item_classes AS "parent"
ON class.parent = parent.id
ORDER BY class.created_at
"#
)
.map(|row| ItemClassListEntry {
id: row.id,
name: row.name,
parent: row
.parent
.map(|id| ItemClassPreview::new(id, row.parent_name.unwrap())),
})
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
Ok(templates::base(
TemplateConfig {
path: "/item-classes",
title: Some("Item Class List"),
page_title: Some(Box::new("Item Class List")),
page_actions: vec![
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: "/item-classes/add".to_string(),
name: "Add".to_string(),
},
colour: Colour::Success,
}),
],
user: Some(user),
..Default::default()
},
html! {
table .table {
thead {
tr {
th { "Name" }
th { "Parents" }
}
}
tbody {
@for item_class in item_classes {
tr {
td { (ItemClassPreview::new(item_class.id, item_class.name)) }
td {
@if let Some(parent) = item_class.parent {
(parent)
} @else {
"-"
}
}
}
}
}
}
},
))
}

View file

@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
mod add;
mod delete;
mod edit;
mod list;
mod show;
use actix_web::web;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.configure(add::config)
.configure(delete::config)
.configure(edit::config)
.configure(list::config)
.configure(show::config);
}

View file

@ -0,0 +1,186 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use maud::html;
use sqlx::{query, query_as, PgPool};
use uuid::Uuid;
use crate::frontend::templates::{
self,
helpers::{
Colour, ItemClassPreview, ItemPreview, PageAction, PageActionGroup, PageActionMethod,
},
TemplateConfig,
};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
struct ItemClassDetails {
id: Uuid,
name: String,
description: String,
parent: Option<ItemClassPreview>,
}
#[get("/item-class/{id}")]
async fn get(
pool: web::Data<PgPool>,
path: web::Path<Uuid>,
user: Identity,
) -> actix_web::Result<impl Responder> {
let id = path.into_inner();
let item_class = query!(
r#"SELECT
class.id,
class.name,
class.description,
class.parent,
parent.name AS "parent_name?"
FROM item_classes AS "class"
LEFT JOIN item_classes AS "parent"
ON class.parent = parent.id
WHERE class.id = $1"#,
id
)
.map(|row| ItemClassDetails {
id: row.id,
name: row.name,
description: row.description,
parent: row
.parent
.map(|id| ItemClassPreview::new(id, row.parent_name.unwrap())),
})
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let children = query_as!(
ItemClassPreview,
"SELECT id, name FROM item_classes WHERE parent = $1",
id
)
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let items = query!(
r#"SELECT items.id, items.name, item_classes.name AS "class_name"
FROM items
JOIN item_classes
ON items.class = item_classes.id
WHERE items.class = $1"#,
id
)
.map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name))
.fetch_all(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?;
let mut title = item_class.name.clone();
title.push_str(" Item Details");
let mut page_actions = vec![
(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/items/add?class={}", item_class.id),
name: "Add Item".to_string(),
},
colour: Colour::Success,
}),
];
if item_class.parent.is_none() {
page_actions.push(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/item-classes/add?parent={}", item_class.id),
name: "Add Child".to_string(),
},
colour: Colour::Primary,
});
}
page_actions.push(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Get,
target: format!("/item-class/{}/edit", item_class.id),
name: "Edit".to_string(),
},
colour: Colour::Warning,
});
page_actions.push(PageActionGroup::Button {
action: PageAction {
method: PageActionMethod::Post,
target: format!("/item-class/{}/delete", item_class.id),
name: "Delete".to_string(),
},
colour: Colour::Danger,
});
Ok(templates::base(
TemplateConfig {
path: &format!("/item-class/{}", item_class.id),
title: Some(&title),
page_title: Some(Box::new(item_class.name.clone())),
page_actions,
user: Some(user),
..Default::default()
},
html! {
table .table {
tr {
th { "UUID" }
td { (item_class.id) }
}
tr {
th { "Name" }
td { (item_class.name) }
}
@if let Some(parent) = item_class.parent {
tr {
th { "Parent" }
td { (parent) }
}
}
tr {
th { "Description" }
td style="white-space: pre-wrap" { (item_class.description) }
}
}
@if !children.is_empty() {
h3 .mt-4 { "Children (" (children.len()) ")" }
ul {
@for child in children {
li { (child) }
}
}
}
@if !items.is_empty() {
div .d-flex.justify-content-between.mt-4 {
div {
h3 { "Items (" (items.len()) ")" }
}
div {
(PageActionGroup::generate_labels(
&items.iter().map(|i| i.id).collect::<Vec<Uuid>>(),
))
}
}
ul {
@for item in items {
li { (item) }
}
}
}
},
))
}

78
src/frontend/jump.rs Normal file
View file

@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use serde::Deserialize;
use sqlx::{query, query_scalar, PgPool};
use uuid::Uuid;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get);
}
#[derive(Deserialize)]
pub enum EntityType {
Item,
ItemClass,
}
#[derive(Deserialize)]
struct JumpData {
id: String,
}
#[get("/jump")]
async fn get(
pool: web::Data<PgPool>,
data: web::Query<JumpData>,
_user: Identity, // this endpoint leaks information about the existence of items
) -> Result<impl Responder, error::Error> {
let mut id = data.id.clone();
let entity_type = if let Ok(id) = Uuid::parse_str(&id) {
query!(
r#"SELECT type as "type!"
FROM (SELECT id, 'item' AS "type" FROM items
UNION ALL
SELECT id, 'item_class' AS "type" FROM item_classes) id_mapping
WHERE id = $1"#,
id
)
.map(|row| match row.r#type.as_str() {
"item" => EntityType::Item,
"item_class" => EntityType::ItemClass,
_ => unreachable!("database returned impossible type"),
})
.fetch_optional(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)?
} else if let Ok(short_id) = id.parse::<i32>() {
if let Ok(id_) = query_scalar!("SELECT id FROM items WHERE short_id = $1", short_id)
.fetch_one(pool.as_ref())
.await
.map_err(error::ErrorInternalServerError)
{
id = id_.to_string();
Some(EntityType::Item)
} else {
None
}
} else {
None
};
if let Some(prefix) = entity_type.map(|entity_type| match entity_type {
EntityType::Item => "item",
EntityType::ItemClass => "item-class",
}) {
Ok(web::Redirect::to(format!("/{prefix}/{id}")).see_other())
} else {
Ok(web::Redirect::to(format!(
"/items/add?{}",
serde_urlencoded::to_string([("name", &id)])?
))
.see_other())
}
}

View file

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

View file

@ -5,25 +5,20 @@
mod auth;
mod item;
mod item_class;
mod jump;
mod labels;
mod templates;
use actix_identity::Identity;
use actix_web::{error, get, web, Responder};
use actix_web::{get, web, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::PgPool;
use uuid::Uuid;
use crate::manage;
use crate::models::EntityType;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(index)
.service(jump)
.configure(auth::config)
.configure(item::config)
.configure(item_class::config)
.configure(jump::config)
.configure(labels::config);
}
@ -37,48 +32,3 @@ async fn index(user: Identity) -> impl Responder {
html! {},
)
}
#[derive(Deserialize)]
struct JumpData {
id: String,
}
#[get("/jump")]
async fn jump(
pool: web::Data<PgPool>,
data: web::Query<JumpData>,
_user: Identity, // this endpoint leaks information about the existence of items
) -> Result<impl Responder, error::Error> {
let mut id = data.id.clone();
let entity_type = if let Ok(id) = Uuid::parse_str(&id) {
manage::query_entity_type(&pool, id)
.await
.map_err(error::ErrorInternalServerError)?
} else if let Ok(short_id) = id.parse::<i32>() {
if let Ok(item) = manage::item::get_by_short_id(&pool, short_id)
.await
.map_err(error::ErrorInternalServerError)
{
id = item.id.to_string();
Some(EntityType::Item)
} else {
None
}
} else {
None
};
if let Some(prefix) = entity_type.map(|entity_type| match entity_type {
EntityType::Item => "item",
EntityType::ItemClass => "item-class",
}) {
Ok(web::Redirect::to(format!("/{prefix}/{id}")).see_other())
} else {
Ok(web::Redirect::to(format!(
"/items/add?{}",
serde_urlencoded::to_string([("name", &id)])?
))
.see_other())
}
}

View file

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

View file

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::fmt;
use std::fmt::{self, Display};
use maud::{html, Markup, Render};
@ -14,7 +14,7 @@ pub enum InputType {
Textarea,
}
impl fmt::Display for InputType {
impl Display for InputType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Text => write!(f, "text"),
@ -30,7 +30,7 @@ pub struct InputGroup<'a> {
pub required: bool,
pub optional: bool,
pub disabled: bool,
pub value: Option<String>,
pub value: Option<&'a dyn Display>,
pub datalist: Option<&'a Datalist>,
}
@ -60,7 +60,7 @@ impl InputGroup<'_> {
required[self.required || force_required]
disabled[self.disabled]
rows="5" // FIXME hardcoded
{ (self.value.as_ref().unwrap_or(&"".to_string())) },
{ (self.value.unwrap_or(&"")) },
_ => input
.form-control
#(self.name)

View file

@ -2,11 +2,9 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use std::fmt::{self, Display};
use crate::label::LabelPreset;
use crate::models::*;
use maud::{html, Markup, PreEscaped, Render};
use uuid::Uuid;
@ -93,11 +91,11 @@ pub enum ItemName {
}
impl ItemName {
pub fn new(item: &Item, class: &ItemClass) -> Self {
if let Some(ref name) = item.name {
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.clone())
Self::Class(class_name.to_string())
}
}
}
@ -133,6 +131,51 @@ impl Render for ItemName {
}
}
pub struct ItemPreview {
pub id: Uuid,
pub name: ItemName,
}
impl ItemPreview {
pub fn new(id: Uuid, name: ItemName) -> Self {
Self { id, name }
}
pub fn from_parts(id: Uuid, item_name: Option<&String>, class_name: &String) -> Self {
Self {
id,
name: ItemName::new(item_name, class_name),
}
}
}
impl Render for ItemPreview {
fn render(&self) -> Markup {
html! {
a href={ "/item/" (self.id) } { (self.name) }
}
}
}
pub struct ItemClassPreview {
pub id: Uuid,
pub name: String,
}
impl ItemClassPreview {
pub fn new(id: Uuid, name: String) -> Self {
Self { id, name }
}
}
impl Render for ItemClassPreview {
fn render(&self) -> Markup {
html! {
a href={ "/item-class/" (self.id) } { (self.name) }
}
}
}
pub enum PageActionMethod {
Get,
Post,
@ -192,7 +235,7 @@ impl Render for PageActionGroup {
}
impl PageActionGroup {
pub fn generate_labels(items: &[&Item]) -> PageActionGroup {
pub fn generate_labels(ids: &[Uuid]) -> PageActionGroup {
PageActionGroup::Dropdown {
name: "Generate Label".to_string(),
actions: enum_iterator::all::<LabelPreset>()
@ -201,9 +244,8 @@ impl PageActionGroup {
target: format!(
"/labels/generate?preset={}&ids={}",
&serde_variant::to_variant_name(&preset).unwrap(),
items
.iter()
.map(|item| item.id.to_string())
ids.iter()
.map(|id| id.to_string())
.collect::<Vec<String>>()
.join(",")
),
@ -215,13 +257,7 @@ impl PageActionGroup {
}
}
pub fn parents_breadcrumb(
item: &Item,
item_class: &ItemClass,
parents: &[Item],
parents_item_classes: &HashMap<Uuid, ItemClass>,
full: bool,
) -> Markup {
pub fn parents_breadcrumb(name: ItemName, parents: &[ItemPreview], full: bool) -> Markup {
const LIMIT: usize = 3;
html! {
@ -229,17 +265,17 @@ pub fn parents_breadcrumb(
@if !full && parents.len() > LIMIT {
li .breadcrumb-item { "" }
}
@let parents: Box<dyn Iterator<Item = &Item>> = if full {
@let parents: Box<dyn Iterator<Item = &ItemPreview>> = if full {
Box::new(parents.iter())
} else {
Box::new(parents.iter().rev().take(LIMIT).rev())
};
@for parent in parents {
li .breadcrumb-item {
a href={ "/item/" (parent.id) } { (ItemName::new(parent, parents_item_classes.get(&parent.class).unwrap()) )}
(parent)
}
}
li .breadcrumb-item.active { (ItemName::new(item, item_class)) }
li .breadcrumb-item.active { (name) }
}
}
}

View file

@ -15,7 +15,6 @@ use helpers::*;
const BRANDING: &str = "li7y";
const NAVBAR_ITEMS: &[(&str, &str)] = &[
("/", "Home"),
("/items", "Items"),
("/item-classes", "Item Classes"),
("/labels", "Labels"),
@ -68,7 +67,7 @@ fn footer() -> Markup {
pub struct TemplateConfig<'a> {
pub path: &'a str,
pub title: Option<&'a str>,
pub page_title: Option<Box<dyn Render>>,
pub page_title: Option<Box<dyn Render + 'a>>,
pub page_actions: Vec<PageActionGroup>,
pub extra_css: Vec<Css<'a>>,
pub extra_js: Vec<Js<'a>>,

View file

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

View file

@ -2,9 +2,6 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod api;
pub mod frontend;
pub mod label;
pub mod manage;
pub mod middleware;
pub mod models;

View file

@ -55,11 +55,6 @@ async fn main() -> std::io::Result<()> {
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(pool.clone()))
.service(
web::scope("/api")
.wrap(li7y::middleware::ForceIdentity)
.configure(li7y::api::config),
)
.service(web::scope("/static").route(
"/{_:.*}",
web::get().to(|path: web::Path<String>| async {

View file

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

View file

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

View file

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

View file

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