Compare commits

..

No commits in common. "a72d5b40e255b0ad708469e115ad6942ede7fd71" and "1195287bc89c068aef66f7f58f885bba247e18bb" have entirely different histories.

72 changed files with 2545 additions and 2063 deletions

View file

@ -4,6 +4,8 @@
on:
push:
branches:
- master
jobs:
build:
@ -23,7 +25,6 @@ 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

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

View file

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

View file

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

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

View file

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

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

View file

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

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

@ -1,32 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id",
"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

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

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

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

@ -1,24 +0,0 @@
{
"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,26 +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 id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"VarcharArray",
"UuidArray",
"UuidArray",
"UuidArray",
"VarcharArray"
]
},
"nullable": [
false
]
},
"hash": "857bbaea0fa73c37679d927aa627ee841c5b89c86af12f32493ba05c0f0ead91"
}

View file

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

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

View file

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

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

View file

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

View file

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

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

@ -0,0 +1,68 @@
{
"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,6 +1,6 @@
{
"db_name": "PostgreSQL",
"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",
"query": "SELECT * FROM item_classes WHERE parent = $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": "parent_name?",
"name": "description",
"type_info": "Varchar"
}
],
@ -35,12 +35,12 @@
]
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "84b4620db57dd9b963e09153c3de5938b3959ae41744098c4e9565404abf09ae"
"hash": "c552c0a40bc8995cb95726a85f1d0c0b86eb2322035e6a720e2e6d425072a8c1"
}

View file

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

@ -0,0 +1,64 @@
{
"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,26 +0,0 @@
{
"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

@ -1,34 +0,0 @@
{
"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,15 +1113,6 @@ 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"
@ -1174,7 +1165,6 @@ dependencies = [
"enum-iterator",
"env_logger",
"futures-util",
"itertools",
"log",
"maud",
"mime",

View file

@ -19,7 +19,6 @@ 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"

11
src/api/mod.rs Normal file
View file

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

94
src/api/v1/item.rs Normal file
View file

@ -0,0 +1,94 @@
// 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))
}

94
src/api/v1/item_class.rs Normal file
View file

@ -0,0 +1,94 @@
// 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())
}

42
src/api/v1/label.rs Normal file
View file

@ -0,0 +1,42 @@
// 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()))
}

15
src/api/v1/mod.rs Normal file
View file

@ -0,0 +1,15 @@
// 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);
}

550
src/frontend/item.rs Normal file
View file

@ -0,0 +1,550 @@
// 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())
}

View file

@ -1,191 +0,0 @@
// 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

@ -1,28 +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, 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())
}

View file

@ -1,175 +0,0 @@
// 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())
}

View file

@ -1,123 +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, 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)) }
}
}
}
}
},
))
}

View file

@ -1,19 +0,0 @@
// 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

@ -1,227 +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, 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) }
}
}
}
},
))
}

408
src/frontend/item_class.rs Normal file
View file

@ -0,0 +1,408 @@
// 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

@ -1,108 +0,0 @@
// 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

@ -1,28 +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, 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

@ -1,126 +0,0 @@
// 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

@ -1,91 +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, 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

@ -1,19 +0,0 @@
// 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

@ -1,186 +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, 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) }
}
}
}
},
))
}

View file

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

View file

@ -5,20 +5,25 @@
mod auth;
mod item;
mod item_class;
mod jump;
mod labels;
mod templates;
use actix_identity::Identity;
use actix_web::{get, web, Responder};
use actix_web::{error, 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);
}
@ -32,3 +37,48 @@ 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,9 +3,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{html, Markup, Render};
use sqlx::{query, PgPool};
use sqlx::PgPool;
use super::helpers::ItemName;
use crate::manage;
pub struct Datalist {
name: String,
@ -43,21 +44,18 @@ 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: 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)),
options: items
.iter()
.map(|i| DatalistOption {
value: i.id.to_string(),
text: Box::new(ItemName::new(i, item_classes.get(&i.class).unwrap())),
})
.collect(),
})
@ -67,13 +65,12 @@ pub async fn item_classes(pool: &PgPool) -> Result<Datalist, sqlx::Error> {
Ok(Datalist {
name: "item-classes".to_string(),
link_prefix: Some("/item-class/".to_string()),
options: query!("SELECT id, name FROM item_classes")
.fetch_all(pool)
options: manage::item_class::get_all(pool)
.await?
.into_iter()
.map(|row| DatalistOption {
value: row.id.to_string(),
text: Box::new(row.name),
.map(|ic| DatalistOption {
value: ic.id.to_string(),
text: Box::new(ic.name),
})
.collect(),
})

View file

@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::fmt::{self, Display};
use std::fmt;
use maud::{html, Markup, Render};
@ -14,7 +14,7 @@ pub enum InputType {
Textarea,
}
impl Display for InputType {
impl fmt::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<&'a dyn Display>,
pub value: Option<String>,
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.unwrap_or(&"")) },
{ (self.value.as_ref().unwrap_or(&"".to_string())) },
_ => input
.form-control
#(self.name)

View file

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

View file

@ -15,6 +15,7 @@ use helpers::*;
const BRANDING: &str = "li7y";
const NAVBAR_ITEMS: &[(&str, &str)] = &[
("/", "Home"),
("/items", "Items"),
("/item-classes", "Item Classes"),
("/labels", "Labels"),
@ -67,7 +68,7 @@ fn footer() -> Markup {
pub struct TemplateConfig<'a> {
pub path: &'a str,
pub title: Option<&'a str>,
pub page_title: Option<Box<dyn Render + 'a>>,
pub page_title: Option<Box<dyn Render>>,
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,18 +247,16 @@ impl Label {
Ok(doc.ok_or(Error::NoPages)?.save_to_bytes()?)
}
pub async fn for_items(pool: &PgPool, ids: &[Uuid], config: LabelConfig) -> sqlx::Result<Self> {
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,
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(),
config,
}
}
}

View file

@ -2,6 +2,9 @@
//
// 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,6 +55,11 @@ 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 {

155
src/manage/item.rs Normal file
View file

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

83
src/manage/item_class.rs Normal file
View file

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

29
src/manage/mod.rs Normal file
View file

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

56
src/models.rs Normal file
View file

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