Compare commits
No commits in common. "master" and "unit-test" have entirely different histories.
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "WITH RECURSIVE item_class_children AS (\n SELECT\n item_classes.id,\n array_remove(array_agg(children.id), NULL) AS \"children\"\n FROM item_classes\n LEFT JOIN item_classes AS \"children\"\n ON item_classes.id = children.parent\n GROUP BY item_classes.id\n ),\n cte AS (\n SELECT\n item_classes.id,\n item_classes.name,\n item_class_children.children,\n 0 AS \"reverse_level\"\n FROM item_classes\n JOIN item_class_children\n ON item_classes.id = item_class_children.id\n WHERE item_class_children.children = '{}'\n\n UNION\n\n SELECT\n item_classes.id,\n item_classes.name,\n item_class_children.children,\n cte.reverse_level + 1\n FROM item_classes\n JOIN item_class_children\n ON item_classes.id = item_class_children.id\n JOIN cte\n ON cte.id = ANY (item_class_children.children)\n )\n SELECT\n id AS \"id!\",\n name AS \"name!\",\n children AS \"children!\"\n FROM cte\n GROUP BY id, name, children\n ORDER BY max(reverse_level)",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id!",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "name!",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 2,
|
|
||||||
"name": "children!",
|
|
||||||
"type_info": "UuidArray"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": []
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "689a5177bdbc4ac788579a47fe033eb6b9639a356fe7ec543691e385ffef51e7"
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "SELECT item_classes.id, item_classes.name\n FROM item_classes\n JOIN unnest((SELECT parents FROM item_class_tree WHERE id = $1))\n WITH ORDINALITY AS parents(id, n)\n ON item_classes.id = parents.id\n ORDER BY parents.n",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "name",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "85e1b1bae08c7edda9671214e6eeece6556236c25253a45c8ddc834751f72694"
|
|
||||||
}
|
|
506
Cargo.lock
generated
506
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -8,7 +8,6 @@ version = "0.0.0"
|
||||||
authors = ["Simon Bruder <simon@sbruder.de>"]
|
authors = ["Simon Bruder <simon@sbruder.de>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
repository = "https://git.sbruder.de/simon/li7y"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix-identity = "0.7.1"
|
actix-identity = "0.7.1"
|
||||||
|
@ -18,7 +17,6 @@ barcoders = { version = "2.0.0", default-features = false, features = ["std"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
clap = { version = "4.5.10", features = ["derive", "env"] }
|
clap = { version = "4.5.10", features = ["derive", "env"] }
|
||||||
datamatrix = "0.3.1"
|
datamatrix = "0.3.1"
|
||||||
embed-licensing = { version = "0.3.1", features = ["current_platform"] }
|
|
||||||
enum-iterator = "2.1.0"
|
enum-iterator = "2.1.0"
|
||||||
env_logger = "0.11.3"
|
env_logger = "0.11.3"
|
||||||
futures-util = "0.3.30"
|
futures-util = "0.3.30"
|
||||||
|
@ -32,15 +30,12 @@ rust-embed = { version = "8.5.0", features = ["actix"] }
|
||||||
serde = { version = "1.0.203", features = ["serde_derive"] }
|
serde = { version = "1.0.203", features = ["serde_derive"] }
|
||||||
serde_urlencoded = "0.7.1"
|
serde_urlencoded = "0.7.1"
|
||||||
serde_variant = "0.1.3"
|
serde_variant = "0.1.3"
|
||||||
spdx = { version = "0.10.6", features = ["text"] }
|
|
||||||
sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] }
|
sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] }
|
||||||
thiserror = "1.0.61"
|
thiserror = "1.0.61"
|
||||||
time = { version = "0.3.36", features = ["parsing", "serde"] }
|
time = { version = "0.3.36", features = ["parsing", "serde"] }
|
||||||
uuid = { version = "1.9.0", features = ["serde", "v4"] }
|
uuid = { version = "1.9.0", features = ["serde", "v4"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
actix-http = "3.8.0"
|
|
||||||
pretty_assertions = "1.4.0"
|
|
||||||
quickcheck = "1.0.3"
|
quickcheck = "1.0.3"
|
||||||
quickcheck_macros = "1.0.0"
|
quickcheck_macros = "1.0.0"
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ allow = [
|
||||||
"Apache-2.0",
|
"Apache-2.0",
|
||||||
"BSD-3-Clause",
|
"BSD-3-Clause",
|
||||||
"MIT",
|
"MIT",
|
||||||
"MPL-2.0",
|
|
||||||
"Unicode-DFS-2016",
|
"Unicode-DFS-2016",
|
||||||
]
|
]
|
||||||
confidence-threshold = 0.95
|
confidence-threshold = 0.95
|
||||||
|
|
|
@ -146,6 +146,7 @@
|
||||||
cargo-deny
|
cargo-deny
|
||||||
cargo-tarpaulin
|
cargo-tarpaulin
|
||||||
cargo-watch
|
cargo-watch
|
||||||
|
clippy
|
||||||
graphviz
|
graphviz
|
||||||
postgresql
|
postgresql
|
||||||
reuse
|
reuse
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
|
||||||
--
|
|
||||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
DROP VIEW item_class_tree;
|
|
||||||
|
|
||||||
DROP TRIGGER prevent_item_class_cycle ON item_classes;
|
|
||||||
DROP FUNCTION check_item_class_cycle;
|
|
||||||
|
|
||||||
CREATE FUNCTION check_item_class_recursion_depth()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NEW.parent IS NULL THEN
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF (SELECT parent FROM item_classes WHERE id = NEW.parent) IS NULL THEN
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RAISE EXCEPTION 'Item classes may only be nested one level deep';
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER prevent_item_class_recursion
|
|
||||||
BEFORE INSERT OR UPDATE ON item_classes
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION check_item_class_recursion_depth();
|
|
|
@ -1,50 +0,0 @@
|
||||||
-- SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
|
||||||
--
|
|
||||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
DROP TRIGGER prevent_item_class_recursion ON item_classes;
|
|
||||||
DROP FUNCTION check_item_class_recursion_depth;
|
|
||||||
|
|
||||||
CREATE FUNCTION check_item_class_cycle()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NEW.parent IS NULL THEN
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NEW.id = NEW.parent THEN
|
|
||||||
RAISE EXCEPTION 'Cycle detected';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF (WITH RECURSIVE cte AS (
|
|
||||||
SELECT id, parent
|
|
||||||
FROM item_classes
|
|
||||||
WHERE id = NEW.parent
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT item_classes.id, item_classes.parent
|
|
||||||
FROM item_classes, cte
|
|
||||||
WHERE item_classes.id = cte.parent
|
|
||||||
)
|
|
||||||
SELECT 1
|
|
||||||
FROM cte
|
|
||||||
WHERE parent = NEW.id
|
|
||||||
LIMIT 1) THEN
|
|
||||||
RAISE EXCEPTION 'Cycle detected';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER prevent_item_class_cycle
|
|
||||||
BEFORE INSERT OR UPDATE ON item_classes
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION check_item_class_cycle();
|
|
||||||
|
|
||||||
CREATE RECURSIVE VIEW item_class_tree (id, parents) AS (
|
|
||||||
SELECT id, ARRAY[]::UUID[] AS parents FROM item_classes WHERE parent IS NULL
|
|
||||||
UNION ALL
|
|
||||||
SELECT item_classes.id, item_class_tree.parents || item_classes.parent FROM item_classes, item_class_tree WHERE item_classes.parent = item_class_tree.id
|
|
||||||
);
|
|
|
@ -2,8 +2,6 @@
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use sqlx::query;
|
use sqlx::query;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
@ -15,12 +13,6 @@ pub struct ItemClassListEntry {
|
||||||
pub parent: Option<ItemClassPreview>,
|
pub parent: Option<ItemClassPreview>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ItemClassTreeElement {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
pub children: Vec<ItemClassTreeElement>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ItemClassRepository {
|
impl ItemClassRepository {
|
||||||
pub async fn list(&self) -> sqlx::Result<Vec<ItemClassListEntry>> {
|
pub async fn list(&self) -> sqlx::Result<Vec<ItemClassListEntry>> {
|
||||||
query!(
|
query!(
|
||||||
|
@ -41,77 +33,4 @@ impl ItemClassRepository {
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn tree(&self) -> sqlx::Result<Vec<ItemClassTreeElement>> {
|
|
||||||
let mut mappings: HashMap<Uuid, ItemClassTreeElement> = HashMap::new();
|
|
||||||
|
|
||||||
for row in query!(
|
|
||||||
r##"WITH RECURSIVE item_class_children AS (
|
|
||||||
SELECT
|
|
||||||
item_classes.id,
|
|
||||||
array_remove(array_agg(children.id), NULL) AS "children"
|
|
||||||
FROM item_classes
|
|
||||||
LEFT JOIN item_classes AS "children"
|
|
||||||
ON item_classes.id = children.parent
|
|
||||||
GROUP BY item_classes.id
|
|
||||||
),
|
|
||||||
cte AS (
|
|
||||||
SELECT
|
|
||||||
item_classes.id,
|
|
||||||
item_classes.name,
|
|
||||||
item_class_children.children,
|
|
||||||
0 AS "reverse_level"
|
|
||||||
FROM item_classes
|
|
||||||
JOIN item_class_children
|
|
||||||
ON item_classes.id = item_class_children.id
|
|
||||||
WHERE item_class_children.children = '{}'
|
|
||||||
|
|
||||||
UNION
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
item_classes.id,
|
|
||||||
item_classes.name,
|
|
||||||
item_class_children.children,
|
|
||||||
cte.reverse_level + 1
|
|
||||||
FROM item_classes
|
|
||||||
JOIN item_class_children
|
|
||||||
ON item_classes.id = item_class_children.id
|
|
||||||
JOIN cte
|
|
||||||
ON cte.id = ANY (item_class_children.children)
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id AS "id!",
|
|
||||||
name AS "name!",
|
|
||||||
children AS "children!"
|
|
||||||
FROM cte
|
|
||||||
GROUP BY id, name, children
|
|
||||||
ORDER BY max(reverse_level)"##
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
let mut children = if row.children.is_empty() {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
row.children
|
|
||||||
.iter()
|
|
||||||
.map(|id| mappings.remove(id).unwrap())
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
children.sort_by(|this, other| this.name.cmp(&other.name));
|
|
||||||
|
|
||||||
mappings.insert(
|
|
||||||
row.id,
|
|
||||||
ItemClassTreeElement {
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
children,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut item_classes = mappings.into_values().collect::<Vec<_>>();
|
|
||||||
item_classes.sort_by(|this, other| this.name.cmp(&other.name));
|
|
||||||
Ok(item_classes)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,12 +9,11 @@ mod edit;
|
||||||
mod list;
|
mod list;
|
||||||
mod show;
|
mod show;
|
||||||
|
|
||||||
use sqlx::{query, PgPool};
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub use add::{ItemClassAddForm, ItemClassAddFormPrefilled};
|
pub use add::{ItemClassAddForm, ItemClassAddFormPrefilled};
|
||||||
pub use edit::ItemClassEditForm;
|
pub use edit::ItemClassEditForm;
|
||||||
pub use list::ItemClassTreeElement;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ItemClassRepository {
|
pub struct ItemClassRepository {
|
||||||
|
@ -37,20 +36,3 @@ impl ItemClassPreview {
|
||||||
Self { id, name }
|
Self { id, name }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ItemClassRepository {
|
|
||||||
pub async fn parents(&self, id: Uuid) -> sqlx::Result<Vec<ItemClassPreview>> {
|
|
||||||
query!(
|
|
||||||
r#"SELECT item_classes.id, item_classes.name
|
|
||||||
FROM item_classes
|
|
||||||
JOIN unnest((SELECT parents FROM item_class_tree WHERE id = $1))
|
|
||||||
WITH ORDINALITY AS parents(id, n)
|
|
||||||
ON item_classes.id = parents.id
|
|
||||||
ORDER BY parents.n"#,
|
|
||||||
id
|
|
||||||
)
|
|
||||||
.map(|row| ItemClassPreview::new(row.id, row.name))
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
use actix_identity::Identity;
|
use actix_identity::Identity;
|
||||||
use actix_web::{error, get, web, Responder};
|
use actix_web::{error, get, web, Responder};
|
||||||
use maud::{html, Render};
|
use maud::html;
|
||||||
|
|
||||||
use crate::database::{items::ItemPreview, ItemRepository};
|
use crate::database::{items::ItemPreview, ItemRepository};
|
||||||
use crate::frontend::templates::{
|
use crate::frontend::templates::{
|
||||||
|
@ -61,13 +61,7 @@ async fn get(
|
||||||
td { (ItemPreview::new(item.id, item.name.clone().terse())) }
|
td { (ItemPreview::new(item.id, item.name.clone().terse())) }
|
||||||
td { (item.state) }
|
td { (item.state) }
|
||||||
td { a href={ "/item-class/" (item.class) } { (item.class_name) } }
|
td { a href={ "/item-class/" (item.class) } { (item.class_name) } }
|
||||||
td {
|
td { (templates::helpers::parents_breadcrumb(item.name, &item.parents, false)) }
|
||||||
(templates::helpers::parents_breadcrumb(
|
|
||||||
&item.name,
|
|
||||||
item.parents.iter().map(|parent| parent as &dyn Render).collect(),
|
|
||||||
false
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
use actix_identity::Identity;
|
use actix_identity::Identity;
|
||||||
use actix_web::{error, get, web, Responder};
|
use actix_web::{error, get, web, Responder};
|
||||||
use maud::{html, Render};
|
use maud::html;
|
||||||
use serde_variant::to_variant_name;
|
use serde_variant::to_variant_name;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
@ -121,13 +121,7 @@ async fn get(
|
||||||
}
|
}
|
||||||
tr {
|
tr {
|
||||||
th { "Parents" }
|
th { "Parents" }
|
||||||
td {
|
td { (templates::helpers::parents_breadcrumb(item.name, &parents, true)) }
|
||||||
(templates::helpers::parents_breadcrumb(
|
|
||||||
&item.name,
|
|
||||||
parents.iter().map(|parent| parent as &dyn Render).collect(),
|
|
||||||
true
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
tr {
|
tr {
|
||||||
th { "Original Packaging" }
|
th { "Original Packaging" }
|
||||||
|
|
|
@ -6,6 +6,7 @@ use actix_identity::Identity;
|
||||||
use actix_web::{error, get, web, Responder};
|
use actix_web::{error, get, web, Responder};
|
||||||
use maud::html;
|
use maud::html;
|
||||||
|
|
||||||
|
use crate::database::item_classes::ItemClassPreview;
|
||||||
use crate::database::ItemClassRepository;
|
use crate::database::ItemClassRepository;
|
||||||
use crate::frontend::templates::{
|
use crate::frontend::templates::{
|
||||||
self,
|
self,
|
||||||
|
@ -23,7 +24,7 @@ async fn get(
|
||||||
user: Identity,
|
user: Identity,
|
||||||
) -> actix_web::Result<impl Responder> {
|
) -> actix_web::Result<impl Responder> {
|
||||||
let item_classes = item_class_repo
|
let item_classes = item_class_repo
|
||||||
.tree()
|
.list()
|
||||||
.await
|
.await
|
||||||
.map_err(error::ErrorInternalServerError)?;
|
.map_err(error::ErrorInternalServerError)?;
|
||||||
|
|
||||||
|
@ -46,9 +47,26 @@ async fn get(
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
html! {
|
html! {
|
||||||
ul {
|
table .table {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Name" }
|
||||||
|
th { "Parents" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
@for item_class in item_classes {
|
@for item_class in item_classes {
|
||||||
(item_class)
|
tr {
|
||||||
|
td { (ItemClassPreview::new(item_class.id, item_class.name)) }
|
||||||
|
td {
|
||||||
|
@if let Some(parent) = item_class.parent {
|
||||||
|
(parent)
|
||||||
|
} @else {
|
||||||
|
"-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
use actix_identity::Identity;
|
use actix_identity::Identity;
|
||||||
use actix_web::{error, get, web, Responder};
|
use actix_web::{error, get, web, Responder};
|
||||||
use maud::{html, Render};
|
use maud::html;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::database::ItemClassRepository;
|
use crate::database::ItemClassRepository;
|
||||||
|
@ -31,11 +31,6 @@ async fn get(
|
||||||
.await
|
.await
|
||||||
.map_err(error::ErrorInternalServerError)?;
|
.map_err(error::ErrorInternalServerError)?;
|
||||||
|
|
||||||
let parents = item_class_repo
|
|
||||||
.parents(id)
|
|
||||||
.await
|
|
||||||
.map_err(error::ErrorInternalServerError)?;
|
|
||||||
|
|
||||||
let children = item_class_repo
|
let children = item_class_repo
|
||||||
.children(id)
|
.children(id)
|
||||||
.await
|
.await
|
||||||
|
@ -49,45 +44,49 @@ async fn get(
|
||||||
let mut title = item_class.name.clone();
|
let mut title = item_class.name.clone();
|
||||||
title.push_str(" – Item Details");
|
title.push_str(" – Item Details");
|
||||||
|
|
||||||
Ok(templates::base(
|
let mut page_actions = vec![
|
||||||
TemplateConfig {
|
(PageActionGroup::Button {
|
||||||
path: &format!("/item-class/{}", item_class.id),
|
|
||||||
title: Some(&title),
|
|
||||||
page_title: Some(Box::new(item_class.name.clone())),
|
|
||||||
page_actions: vec![
|
|
||||||
PageActionGroup::Button {
|
|
||||||
action: PageAction {
|
action: PageAction {
|
||||||
method: PageActionMethod::Get,
|
method: PageActionMethod::Get,
|
||||||
target: format!("/items/add?class={}", item_class.id),
|
target: format!("/items/add?class={}", item_class.id),
|
||||||
name: "Add Item".to_string(),
|
name: "Add Item".to_string(),
|
||||||
},
|
},
|
||||||
colour: Colour::Success,
|
colour: Colour::Success,
|
||||||
},
|
}),
|
||||||
PageActionGroup::Button {
|
];
|
||||||
|
if item_class.parent.is_none() {
|
||||||
|
page_actions.push(PageActionGroup::Button {
|
||||||
action: PageAction {
|
action: PageAction {
|
||||||
method: PageActionMethod::Get,
|
method: PageActionMethod::Get,
|
||||||
target: format!("/item-classes/add?parent={}", item_class.id),
|
target: format!("/item-classes/add?parent={}", item_class.id),
|
||||||
name: "Add Child".to_string(),
|
name: "Add Child".to_string(),
|
||||||
},
|
},
|
||||||
colour: Colour::Primary,
|
colour: Colour::Primary,
|
||||||
},
|
});
|
||||||
PageActionGroup::Button {
|
}
|
||||||
|
page_actions.push(PageActionGroup::Button {
|
||||||
action: PageAction {
|
action: PageAction {
|
||||||
method: PageActionMethod::Get,
|
method: PageActionMethod::Get,
|
||||||
target: format!("/item-class/{}/edit", item_class.id),
|
target: format!("/item-class/{}/edit", item_class.id),
|
||||||
name: "Edit".to_string(),
|
name: "Edit".to_string(),
|
||||||
},
|
},
|
||||||
colour: Colour::Warning,
|
colour: Colour::Warning,
|
||||||
},
|
});
|
||||||
PageActionGroup::Button {
|
page_actions.push(PageActionGroup::Button {
|
||||||
action: PageAction {
|
action: PageAction {
|
||||||
method: PageActionMethod::Post,
|
method: PageActionMethod::Post,
|
||||||
target: format!("/item-class/{}/delete", item_class.id),
|
target: format!("/item-class/{}/delete", item_class.id),
|
||||||
name: "Delete".to_string(),
|
name: "Delete".to_string(),
|
||||||
},
|
},
|
||||||
colour: Colour::Danger,
|
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),
|
user: Some(user),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
@ -101,14 +100,10 @@ async fn get(
|
||||||
th { "Name" }
|
th { "Name" }
|
||||||
td { (item_class.name) }
|
td { (item_class.name) }
|
||||||
}
|
}
|
||||||
|
@if let Some(parent) = item_class.parent {
|
||||||
tr {
|
tr {
|
||||||
th { "Parents" }
|
th { "Parent" }
|
||||||
td {
|
td { (parent) }
|
||||||
(templates::helpers::parents_breadcrumb(
|
|
||||||
&item_class.name,
|
|
||||||
parents.iter().map(|parent| parent as &dyn Render).collect(),
|
|
||||||
true
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tr {
|
tr {
|
||||||
|
|
|
@ -1,488 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
use std::collections::{BTreeSet, VecDeque};
|
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use actix_identity::Identity;
|
|
||||||
use actix_web::{get, web, Responder};
|
|
||||||
use embed_licensing::{CrateLicense, Licensing};
|
|
||||||
use maud::{html, Markup, Render};
|
|
||||||
use spdx::expression::ExprNode;
|
|
||||||
use spdx::LicenseReq;
|
|
||||||
|
|
||||||
use super::templates;
|
|
||||||
|
|
||||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
|
||||||
cfg.service(get);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialOrd, PartialEq, Eq, Ord)]
|
|
||||||
enum SpdxExpression {
|
|
||||||
Req(LicenseReq),
|
|
||||||
And(BTreeSet<SpdxExpression>),
|
|
||||||
Or(BTreeSet<SpdxExpression>),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_separated_list(list: impl IntoIterator<Item: Render>, separator: &str) -> Markup {
|
|
||||||
let mut iter = list.into_iter();
|
|
||||||
html! {
|
|
||||||
"("
|
|
||||||
(iter.next().unwrap())
|
|
||||||
@for item in iter {
|
|
||||||
small .font-monospace { " " (separator) " " }
|
|
||||||
(item)
|
|
||||||
}
|
|
||||||
")"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: remove the outermost parentheses
|
|
||||||
impl Render for SpdxExpression {
|
|
||||||
fn render(&self) -> Markup {
|
|
||||||
match self {
|
|
||||||
Self::Req(req) => html! {
|
|
||||||
@let license = req.license.id().expect("only SPDX license identifiers supported");
|
|
||||||
a .font-monospace href={ "#license-" (license.name) } { (license.name) }
|
|
||||||
@if let Some(exception) = req.exception {
|
|
||||||
small .font-monospace { " WITH " }
|
|
||||||
a .font-monospace href={ "#exception-" (exception.name) } { (exception.name) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Self::And(items) => render_separated_list(items, "AND"),
|
|
||||||
Self::Or(items) => render_separated_list(items, "OR"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&spdx::Expression> for SpdxExpression {
|
|
||||||
fn from(value: &spdx::Expression) -> Self {
|
|
||||||
let mut stack = VecDeque::new();
|
|
||||||
let mut expr = None;
|
|
||||||
|
|
||||||
for node in value.iter() {
|
|
||||||
match node {
|
|
||||||
ExprNode::Op(op) => {
|
|
||||||
let last = expr.unwrap_or_else(|| stack.pop_back().unwrap());
|
|
||||||
expr = Some(match op {
|
|
||||||
spdx::expression::Operator::Or => {
|
|
||||||
SpdxExpression::Or(BTreeSet::from([last, stack.pop_back().unwrap()]))
|
|
||||||
}
|
|
||||||
spdx::expression::Operator::And => {
|
|
||||||
SpdxExpression::And(BTreeSet::from([last, stack.pop_back().unwrap()]))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ExprNode::Req(req) => {
|
|
||||||
stack.push_back(SpdxExpression::Req(req.req.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// special case for single req
|
|
||||||
if expr.is_none() && stack.len() == 1 {
|
|
||||||
return stack.pop_back().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
expr.expect("empty expression not possible").simplify()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! spdx_expression_simplify_impl {
|
|
||||||
( $variant:ident, $items:expr ) => {{
|
|
||||||
let mut changed = false;
|
|
||||||
for item in $items.clone().into_iter() {
|
|
||||||
// if is split to avoid referencing a moved value
|
|
||||||
if let Self::$variant(_) = item {
|
|
||||||
$items.remove(&item);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if let Self::$variant(mut inner_items) = item {
|
|
||||||
$items.append(&mut inner_items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if changed {
|
|
||||||
Self::$variant($items).simplify()
|
|
||||||
} else {
|
|
||||||
Self::$variant($items.into_iter().map(|it| it.simplify()).collect())
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SpdxExpression {
|
|
||||||
fn simplify(self) -> Self {
|
|
||||||
match self {
|
|
||||||
Self::Req(_) => self,
|
|
||||||
Self::And(mut items) => spdx_expression_simplify_impl!(And, items),
|
|
||||||
Self::Or(mut items) => spdx_expression_simplify_impl!(Or, items),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trait Package {
|
|
||||||
fn name(&self) -> &str;
|
|
||||||
fn version(&self) -> Option<&str>;
|
|
||||||
fn authors(&self) -> &[String];
|
|
||||||
fn website(&self) -> &str;
|
|
||||||
fn license(&self) -> &CrateLicense;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for dyn Package {
|
|
||||||
fn render(&self) -> Markup {
|
|
||||||
html! {
|
|
||||||
div .col-3 {
|
|
||||||
div .card {
|
|
||||||
div .card-body {
|
|
||||||
h4 .card-title {
|
|
||||||
a href=(self.website()) { (self.name()) }
|
|
||||||
@if let Some(version) = self.version() {
|
|
||||||
" " (version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ul .list-group .list-group-flush {
|
|
||||||
@if self.authors().is_empty() {
|
|
||||||
li .list-group-item { em { "no authors specified in Cargo manifest" } }
|
|
||||||
}
|
|
||||||
@let author_limit = 3;
|
|
||||||
@let authors = self.authors().iter().take(author_limit);
|
|
||||||
@let more_authors = self.authors().iter().skip(author_limit);
|
|
||||||
@for author in authors {
|
|
||||||
li .list-group-item { (author) }
|
|
||||||
}
|
|
||||||
@if self.authors().len() > author_limit {
|
|
||||||
@for author in more_authors {
|
|
||||||
li .list-group-item.d-none.author-hidden { (author) }
|
|
||||||
}
|
|
||||||
li
|
|
||||||
.list-group-item.text-center.bg-secondary-subtle.author-expand
|
|
||||||
style="cursor: pointer; user-select: none;"
|
|
||||||
data-expanded="false"
|
|
||||||
{ "⮟" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
div .card-body.align-content-end {
|
|
||||||
@match self.license() {
|
|
||||||
CrateLicense::SpdxExpression(expr) => {
|
|
||||||
(SpdxExpression::from(expr))
|
|
||||||
},
|
|
||||||
CrateLicense::Other(content) => {
|
|
||||||
@let modal_id = format!("license-modal-{}-{}", self.name(), self.version().unwrap_or(""));
|
|
||||||
button
|
|
||||||
.btn.btn-primary
|
|
||||||
type="button"
|
|
||||||
data-bs-toggle="modal"
|
|
||||||
data-bs-target={ "#" (modal_id) }
|
|
||||||
{ "custom license" }
|
|
||||||
div .modal.modal-lg #(modal_id) {
|
|
||||||
div .modal-dialog {
|
|
||||||
div .modal-content {
|
|
||||||
div.modal-header {
|
|
||||||
h1 .modal-title.fs-5 {
|
|
||||||
"custom license of "
|
|
||||||
(self.name())
|
|
||||||
@if let Some(version) = self.version() {
|
|
||||||
" " (version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
button .btn-close type="button" data-bs-dismiss="modal";
|
|
||||||
}
|
|
||||||
div .modal-body {
|
|
||||||
pre style="text-wrap: wrap" { (content) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Package for embed_licensing::Crate {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
&self.name
|
|
||||||
}
|
|
||||||
|
|
||||||
fn version(&self) -> Option<&str> {
|
|
||||||
Some(&self.version)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn authors(&self) -> &[String] {
|
|
||||||
&self.authors
|
|
||||||
}
|
|
||||||
|
|
||||||
fn website(&self) -> &str {
|
|
||||||
&self.website
|
|
||||||
}
|
|
||||||
|
|
||||||
fn license(&self) -> &CrateLicense {
|
|
||||||
&self.license
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct OtherPackage {
|
|
||||||
name: String,
|
|
||||||
website: String,
|
|
||||||
authors: Vec<String>,
|
|
||||||
license: CrateLicense,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Package for OtherPackage {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
&self.name
|
|
||||||
}
|
|
||||||
|
|
||||||
fn version(&self) -> Option<&str> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn authors(&self) -> &[String] {
|
|
||||||
&self.authors
|
|
||||||
}
|
|
||||||
|
|
||||||
fn website(&self) -> &str {
|
|
||||||
&self.website
|
|
||||||
}
|
|
||||||
|
|
||||||
fn license(&self) -> &CrateLicense {
|
|
||||||
&self.license
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn other_packages() -> &'static [OtherPackage] {
|
|
||||||
static OTHER_PACKAGES: OnceLock<Vec<OtherPackage>> = OnceLock::new();
|
|
||||||
|
|
||||||
OTHER_PACKAGES.get_or_init(|| {
|
|
||||||
vec![OtherPackage {
|
|
||||||
name: "Bootstrap".to_string(),
|
|
||||||
website: "https://getbootstrap.com/".to_string(),
|
|
||||||
authors: vec!["The Bootstrap Authors".to_string()],
|
|
||||||
license: CrateLicense::SpdxExpression(spdx::Expression::parse("MIT").unwrap()),
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn licensing() -> &'static Licensing {
|
|
||||||
static LICENSING: OnceLock<Licensing> = OnceLock::new();
|
|
||||||
|
|
||||||
LICENSING.get_or_init(|| {
|
|
||||||
let mut licensing = embed_licensing::collect!(platform(current));
|
|
||||||
|
|
||||||
for other_package in other_packages() {
|
|
||||||
if let CrateLicense::SpdxExpression(expr) = &other_package.license {
|
|
||||||
for node in expr.iter() {
|
|
||||||
if let spdx::expression::ExprNode::Req(spdx::expression::ExpressionReq {
|
|
||||||
req,
|
|
||||||
..
|
|
||||||
}) = node
|
|
||||||
{
|
|
||||||
let license = req
|
|
||||||
.license
|
|
||||||
.id()
|
|
||||||
.expect("only SPDX license identifiers supported");
|
|
||||||
|
|
||||||
if !licensing.licenses.contains(&license) {
|
|
||||||
licensing.licenses.push(license)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(exception) = req.exception {
|
|
||||||
if !licensing.exceptions.contains(&exception) {
|
|
||||||
licensing.exceptions.push(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
licensing.licenses.sort_unstable();
|
|
||||||
licensing.exceptions.sort_unstable();
|
|
||||||
|
|
||||||
licensing
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/licensing")]
|
|
||||||
async fn get(user: Option<Identity>) -> impl Responder {
|
|
||||||
let licensing = licensing();
|
|
||||||
|
|
||||||
templates::base(
|
|
||||||
templates::TemplateConfig {
|
|
||||||
path: "/licensing",
|
|
||||||
title: Some("Licensing"),
|
|
||||||
page_title: Some(Box::new("Licensing")),
|
|
||||||
user,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
html! {
|
|
||||||
h3 .mt-4 { "Rust Packages" }
|
|
||||||
|
|
||||||
div .row.g-2 {
|
|
||||||
@for package in &licensing.packages {
|
|
||||||
(package as &dyn Package)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 .mt-4 { "Other Packages" }
|
|
||||||
|
|
||||||
div .row.g-2 {
|
|
||||||
@for package in other_packages() {
|
|
||||||
(package as &dyn Package)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 .mt-4 { "Licenses" }
|
|
||||||
|
|
||||||
@for license in &licensing.licenses {
|
|
||||||
h4 #{ "license-" (license.name) } .mt-3 { (license.full_name) " (" span .font-monospace { (license.name) } ")" }
|
|
||||||
|
|
||||||
pre style="text-wrap: wrap" { (license.text()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 .mt-4 { "Exceptions" }
|
|
||||||
|
|
||||||
@for exception in &licensing.exceptions {
|
|
||||||
h4 #{ "exception-" (exception.name) } .mt-3.font-monospace { (exception.name) }
|
|
||||||
|
|
||||||
pre style="text-wrap: wrap" { (exception.text()) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
use spdx::{exception_id, license_id, LicenseItem, LicenseReq};
|
|
||||||
|
|
||||||
use super::SpdxExpression;
|
|
||||||
|
|
||||||
fn req(id: &str, or_later: bool, exception: Option<&str>) -> SpdxExpression {
|
|
||||||
SpdxExpression::Req(LicenseReq {
|
|
||||||
license: LicenseItem::Spdx {
|
|
||||||
id: license_id(id).unwrap(),
|
|
||||||
or_later,
|
|
||||||
},
|
|
||||||
exception: exception.map(exception_id).map(Option::unwrap),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_spdx_expression_from_simple() {
|
|
||||||
assert_eq!(
|
|
||||||
SpdxExpression::from(&spdx::Expression::parse("MIT").unwrap()),
|
|
||||||
req("MIT", false, None)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_spdx_expression_from_complex() {
|
|
||||||
assert_eq!(
|
|
||||||
SpdxExpression::from(
|
|
||||||
&spdx::Expression::parse("Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT OR (CC0-1.0 AND Unlicense)")
|
|
||||||
.unwrap()
|
|
||||||
),
|
|
||||||
SpdxExpression::Or(BTreeSet::from([
|
|
||||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
|
||||||
req("Apache-2.0", false, None),
|
|
||||||
req("MIT", false, None),
|
|
||||||
SpdxExpression::And(BTreeSet::from([
|
|
||||||
req("CC0-1.0", false, None),
|
|
||||||
req("Unlicense", false, None),
|
|
||||||
]))
|
|
||||||
]))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_spdx_expression_simplify_or() {
|
|
||||||
assert_eq!(
|
|
||||||
SpdxExpression::Or(BTreeSet::from([
|
|
||||||
SpdxExpression::Or(BTreeSet::from([])),
|
|
||||||
SpdxExpression::Or(BTreeSet::from([
|
|
||||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
|
||||||
req("MIT", false, None),
|
|
||||||
SpdxExpression::Or(BTreeSet::from([
|
|
||||||
req("MIT", false, None),
|
|
||||||
req("Unlicense", false, None),
|
|
||||||
])),
|
|
||||||
])),
|
|
||||||
req("LGPL-2.1", true, None),
|
|
||||||
]))
|
|
||||||
.simplify(),
|
|
||||||
SpdxExpression::Or(BTreeSet::from([
|
|
||||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
|
||||||
req("LGPL-2.1", true, None),
|
|
||||||
req("MIT", false, None),
|
|
||||||
req("Unlicense", false, None),
|
|
||||||
]))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_spdx_expression_simplify_and() {
|
|
||||||
assert_eq!(
|
|
||||||
SpdxExpression::And(BTreeSet::from([
|
|
||||||
SpdxExpression::And(BTreeSet::from([])),
|
|
||||||
SpdxExpression::And(BTreeSet::from([
|
|
||||||
req("MIT", false, None),
|
|
||||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
|
||||||
SpdxExpression::And(BTreeSet::from([
|
|
||||||
req("MIT", false, None),
|
|
||||||
req("Unlicense", false, None),
|
|
||||||
])),
|
|
||||||
])),
|
|
||||||
req("LGPL-2.1", true, None),
|
|
||||||
]))
|
|
||||||
.simplify(),
|
|
||||||
SpdxExpression::And(BTreeSet::from([
|
|
||||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
|
||||||
req("LGPL-2.1", true, None),
|
|
||||||
req("MIT", false, None),
|
|
||||||
req("Unlicense", false, None),
|
|
||||||
]))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_spdx_expression_simplify_mixed() {
|
|
||||||
assert_eq!(
|
|
||||||
SpdxExpression::Or(BTreeSet::from([
|
|
||||||
SpdxExpression::And(BTreeSet::from([
|
|
||||||
req("MIT", false, None),
|
|
||||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
|
||||||
])),
|
|
||||||
SpdxExpression::Or(BTreeSet::from([
|
|
||||||
req("MIT", false, None),
|
|
||||||
req("Unlicense", false, None),
|
|
||||||
])),
|
|
||||||
SpdxExpression::And(BTreeSet::from([
|
|
||||||
req("CC0-1.0", false, None),
|
|
||||||
req("GPL-3.0", false, None),
|
|
||||||
])),
|
|
||||||
req("LGPL-2.1", true, None),
|
|
||||||
]))
|
|
||||||
.simplify(),
|
|
||||||
SpdxExpression::Or(BTreeSet::from([
|
|
||||||
req("MIT", false, None),
|
|
||||||
req("Unlicense", false, None),
|
|
||||||
req("LGPL-2.1", true, None),
|
|
||||||
SpdxExpression::And(BTreeSet::from([
|
|
||||||
req("MIT", false, None),
|
|
||||||
req("Apache-2.0", false, Some("LLVM-exception")),
|
|
||||||
])),
|
|
||||||
SpdxExpression::And(BTreeSet::from([
|
|
||||||
req("CC0-1.0", false, None),
|
|
||||||
req("GPL-3.0", false, None),
|
|
||||||
])),
|
|
||||||
]))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -7,7 +7,6 @@ mod item;
|
||||||
mod item_class;
|
mod item_class;
|
||||||
mod jump;
|
mod jump;
|
||||||
mod labels;
|
mod labels;
|
||||||
mod licensing;
|
|
||||||
pub mod templates;
|
pub mod templates;
|
||||||
|
|
||||||
use actix_identity::Identity;
|
use actix_identity::Identity;
|
||||||
|
@ -20,8 +19,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||||
.configure(item::config)
|
.configure(item::config)
|
||||||
.configure(item_class::config)
|
.configure(item_class::config)
|
||||||
.configure(jump::config)
|
.configure(jump::config)
|
||||||
.configure(labels::config)
|
.configure(labels::config);
|
||||||
.configure(licensing::config);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
|
|
|
@ -7,6 +7,7 @@ use std::fmt;
|
||||||
use maud::{html, Markup, PreEscaped, Render};
|
use maud::{html, Markup, PreEscaped, Render};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::database::items::{ItemName, ItemPreview};
|
||||||
use crate::label::LabelPreset;
|
use crate::label::LabelPreset;
|
||||||
|
|
||||||
pub enum Css<'a> {
|
pub enum Css<'a> {
|
||||||
|
@ -169,7 +170,7 @@ impl PageActionGroup {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parents_breadcrumb(name: &dyn Render, parents: Vec<&dyn Render>, full: bool) -> Markup {
|
pub fn parents_breadcrumb(name: ItemName, parents: &[ItemPreview], full: bool) -> Markup {
|
||||||
const LIMIT: usize = 3;
|
const LIMIT: usize = 3;
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
|
@ -177,10 +178,10 @@ pub fn parents_breadcrumb(name: &dyn Render, parents: Vec<&dyn Render>, full: bo
|
||||||
@if !full && parents.len() > LIMIT {
|
@if !full && parents.len() > LIMIT {
|
||||||
li .breadcrumb-item { "…" }
|
li .breadcrumb-item { "…" }
|
||||||
}
|
}
|
||||||
@let parents: Box<dyn Iterator<Item = &dyn Render>> = if full {
|
@let parents: Box<dyn Iterator<Item = &ItemPreview>> = if full {
|
||||||
Box::new(parents.into_iter())
|
Box::new(parents.iter())
|
||||||
} else {
|
} else {
|
||||||
Box::new(parents.into_iter().rev().take(LIMIT).rev())
|
Box::new(parents.iter().rev().take(LIMIT).rev())
|
||||||
};
|
};
|
||||||
@for parent in parents {
|
@for parent in parents {
|
||||||
li .breadcrumb-item {
|
li .breadcrumb-item {
|
||||||
|
|
|
@ -59,13 +59,7 @@ fn footer() -> Markup {
|
||||||
html! {
|
html! {
|
||||||
footer .mt-auto.py-3.bg-body-tertiary.text-body-tertiary {
|
footer .mt-auto.py-3.bg-body-tertiary.text-body-tertiary {
|
||||||
div .container {
|
div .container {
|
||||||
p .mb-0 {
|
p .mb-0 { "li7y is free software, released under the terms of the AGPL v3" }
|
||||||
"li7y is free software, released under the terms of the AGPL v3 ("
|
|
||||||
a href="https://git.sbruder.de/simon/li7y" { "source code" }
|
|
||||||
", "
|
|
||||||
a href="/licensing" { "licensing of all dependencies" }
|
|
||||||
")"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,11 +7,7 @@ use std::fmt::{self, Display};
|
||||||
use maud::{html, Markup, Render};
|
use maud::{html, Markup, Render};
|
||||||
|
|
||||||
use crate::database::items::ItemPreview;
|
use crate::database::items::ItemPreview;
|
||||||
use crate::database::{
|
use crate::database::{item_classes::ItemClassPreview, item_events::ItemEvent, items::ItemName};
|
||||||
item_classes::{ItemClassPreview, ItemClassTreeElement},
|
|
||||||
item_events::ItemEvent,
|
|
||||||
items::ItemName,
|
|
||||||
};
|
|
||||||
|
|
||||||
impl Render for ItemClassPreview {
|
impl Render for ItemClassPreview {
|
||||||
fn render(&self) -> Markup {
|
fn render(&self) -> Markup {
|
||||||
|
@ -72,20 +68,3 @@ impl Render for ItemPreview {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for ItemClassTreeElement {
|
|
||||||
fn render(&self) -> Markup {
|
|
||||||
html! {
|
|
||||||
li {
|
|
||||||
(ItemClassPreview::new(self.id, self.name.clone()))
|
|
||||||
@if !self.children.is_empty() {
|
|
||||||
ul {
|
|
||||||
@for child in &self.children {
|
|
||||||
(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
76
src/lib.rs
76
src/lib.rs
|
@ -11,79 +11,3 @@ pub mod middleware;
|
||||||
mod test_utils;
|
mod test_utils;
|
||||||
|
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
|
|
||||||
use actix_identity::IdentityMiddleware;
|
|
||||||
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
|
|
||||||
use actix_web::middleware::ErrorHandlers;
|
|
||||||
use actix_web::{cookie::Key, http::StatusCode, web, App, HttpResponse};
|
|
||||||
use base64::prelude::{Engine as _, BASE64_STANDARD};
|
|
||||||
use log::warn;
|
|
||||||
use mime_guess::from_path;
|
|
||||||
use rust_embed::Embed;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
use crate::database::{
|
|
||||||
ItemClassRepository, ItemEventRepository, ItemRepository, ItemStateRepository,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Embed)]
|
|
||||||
#[folder = "static"]
|
|
||||||
struct Static;
|
|
||||||
|
|
||||||
pub fn app(
|
|
||||||
config: &Config,
|
|
||||||
pool: &PgPool,
|
|
||||||
) -> App<
|
|
||||||
impl actix_web::dev::ServiceFactory<
|
|
||||||
actix_web::dev::ServiceRequest,
|
|
||||||
Config = (),
|
|
||||||
Response = actix_web::dev::ServiceResponse<
|
|
||||||
actix_web::body::EitherBody<actix_web::body::BoxBody>,
|
|
||||||
>,
|
|
||||||
Error = actix_web::Error,
|
|
||||||
InitError = (),
|
|
||||||
>,
|
|
||||||
> {
|
|
||||||
let secret_key = match config.secret_key {
|
|
||||||
Some(ref encoded) => Key::from(
|
|
||||||
&BASE64_STANDARD
|
|
||||||
.decode(encoded)
|
|
||||||
.expect("failed to decode base64 in SECRET_KEY"),
|
|
||||||
),
|
|
||||||
None => {
|
|
||||||
warn!("SECRET_KEY was not specified, using randomly generated key");
|
|
||||||
Key::generate()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
App::new()
|
|
||||||
.app_data(web::Data::new(config.clone()))
|
|
||||||
.app_data(web::Data::new(pool.clone()))
|
|
||||||
.app_data(web::Data::new(ItemClassRepository::new(pool.clone())))
|
|
||||||
.app_data(web::Data::new(ItemEventRepository::new(pool.clone())))
|
|
||||||
.app_data(web::Data::new(ItemStateRepository::new(pool.clone())))
|
|
||||||
.app_data(web::Data::new(ItemRepository::new(pool.clone())))
|
|
||||||
.service(web::scope("/static").route(
|
|
||||||
"/{_:.*}",
|
|
||||||
web::get().to(|path: web::Path<String>| async {
|
|
||||||
Static::get(&path)
|
|
||||||
.map(|embedded_file| match from_path(path.into_inner()).first() {
|
|
||||||
Some(mime_type) => HttpResponse::Ok()
|
|
||||||
.content_type(mime_type)
|
|
||||||
.body(embedded_file.data),
|
|
||||||
None => HttpResponse::Ok().body(embedded_file.data),
|
|
||||||
})
|
|
||||||
.unwrap_or(HttpResponse::NotFound().body(()))
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
.configure(frontend::config)
|
|
||||||
.wrap(ErrorHandlers::new().handler(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
middleware::error_handlers::redirect_to_login,
|
|
||||||
))
|
|
||||||
.wrap(IdentityMiddleware::default())
|
|
||||||
.wrap(SessionMiddleware::new(
|
|
||||||
CookieSessionStore::default(),
|
|
||||||
secret_key.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
66
src/main.rs
66
src/main.rs
|
@ -2,20 +2,45 @@
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use actix_web::HttpServer;
|
use actix_identity::IdentityMiddleware;
|
||||||
|
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
|
||||||
|
use actix_web::middleware::ErrorHandlers;
|
||||||
|
use actix_web::{cookie::Key, http::StatusCode, web, App, HttpResponse, HttpServer};
|
||||||
|
use base64::prelude::{Engine as _, BASE64_STANDARD};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use log::info;
|
use li7y::database::item_events::ItemEventRepository;
|
||||||
|
use li7y::database::item_states::ItemStateRepository;
|
||||||
|
use li7y::database::items::ItemRepository;
|
||||||
|
use li7y::database::ItemClassRepository;
|
||||||
|
use log::{info, warn};
|
||||||
|
use mime_guess::from_path;
|
||||||
|
use rust_embed::Embed;
|
||||||
|
|
||||||
use li7y::Config;
|
use li7y::Config;
|
||||||
|
|
||||||
|
#[derive(Embed)]
|
||||||
|
#[folder = "static"]
|
||||||
|
struct Static;
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
let config = Config::parse();
|
let config = Config::parse();
|
||||||
|
|
||||||
// This can’t be included in app, because app gets called in a (non-async) closure
|
let secret_key = match config.secret_key {
|
||||||
let pool = sqlx::Pool::<sqlx::postgres::Postgres>::connect(&config.database_url)
|
Some(ref encoded) => Key::from(
|
||||||
|
&BASE64_STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.expect("failed to decode base64 in SECRET_KEY"),
|
||||||
|
),
|
||||||
|
None => {
|
||||||
|
warn!("SECRET_KEY was not specified, using randomly generated key");
|
||||||
|
Key::generate()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool: sqlx::PgPool = sqlx::Pool::<sqlx::postgres::Postgres>::connect(&config.database_url)
|
||||||
.await
|
.await
|
||||||
.expect("failed to connect to database");
|
.expect("failed to connect to database");
|
||||||
|
|
||||||
|
@ -29,7 +54,38 @@ async fn main() -> std::io::Result<()> {
|
||||||
|
|
||||||
info!("Starting on {address}:{port}");
|
info!("Starting on {address}:{port}");
|
||||||
|
|
||||||
HttpServer::new(move || li7y::app(&config, &pool))
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.app_data(web::Data::new(config.clone()))
|
||||||
|
.app_data(web::Data::new(pool.clone()))
|
||||||
|
.app_data(web::Data::new(ItemClassRepository::new(pool.clone())))
|
||||||
|
.app_data(web::Data::new(ItemEventRepository::new(pool.clone())))
|
||||||
|
.app_data(web::Data::new(ItemStateRepository::new(pool.clone())))
|
||||||
|
.app_data(web::Data::new(ItemRepository::new(pool.clone())))
|
||||||
|
.service(web::scope("/static").route(
|
||||||
|
"/{_:.*}",
|
||||||
|
web::get().to(|path: web::Path<String>| async {
|
||||||
|
Static::get(&path)
|
||||||
|
.map(|embedded_file| match from_path(path.into_inner()).first() {
|
||||||
|
Some(mime_type) => HttpResponse::Ok()
|
||||||
|
.content_type(mime_type)
|
||||||
|
.body(embedded_file.data),
|
||||||
|
None => HttpResponse::Ok().body(embedded_file.data),
|
||||||
|
})
|
||||||
|
.unwrap_or(HttpResponse::NotFound().body(()))
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.configure(li7y::frontend::config)
|
||||||
|
.wrap(ErrorHandlers::new().handler(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
li7y::middleware::error_handlers::redirect_to_login,
|
||||||
|
))
|
||||||
|
.wrap(IdentityMiddleware::default())
|
||||||
|
.wrap(SessionMiddleware::new(
|
||||||
|
CookieSessionStore::default(),
|
||||||
|
secret_key.clone(),
|
||||||
|
))
|
||||||
|
})
|
||||||
.bind((address, port))?
|
.bind((address, port))?
|
||||||
.run()
|
.run()
|
||||||
.await
|
.await
|
||||||
|
|
|
@ -16,8 +16,9 @@
|
||||||
|
|
||||||
const datalistHint = (input, hint) => {
|
const datalistHint = (input, hint) => {
|
||||||
const selected = input.list.querySelector(`option[value="${input.value}"]`);
|
const selected = input.list.querySelector(`option[value="${input.value}"]`);
|
||||||
hint.innerHTML = ""
|
if (selected === null)
|
||||||
if (selected !== null) {
|
hint.innerText = ""
|
||||||
|
else {
|
||||||
const linkPrefix = input.list.dataset.linkPrefix;
|
const linkPrefix = input.list.dataset.linkPrefix;
|
||||||
if (linkPrefix === undefined) {
|
if (linkPrefix === undefined) {
|
||||||
hint.innerHTML = selected.innerHTML
|
hint.innerHTML = selected.innerHTML
|
||||||
|
@ -85,32 +86,7 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addEventModal = document.getElementById("add-event-modal")
|
document.getElementById("add-event-modal").addEventListener("show.bs.modal", e => {
|
||||||
if (addEventModal !== null) {
|
|
||||||
addEventModal.addEventListener("show.bs.modal", e => {
|
|
||||||
document.getElementById("event").value = e.relatedTarget.dataset.eventType
|
document.getElementById("event").value = e.relatedTarget.dataset.eventType
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
Array.from(document.getElementsByClassName("author-expand")).forEach(expanderEl => {
|
|
||||||
expanderEl.addEventListener("click", _ => {
|
|
||||||
// it implicitly converts true/false to "true"/"false" in the dataset,
|
|
||||||
// but does not convert "true"/"false" from the datast into true/false
|
|
||||||
expanderEl.dataset.expanded = expanderEl.dataset.expanded !== "true"
|
|
||||||
|
|
||||||
if (expanderEl.dataset.expanded === "true") {
|
|
||||||
expanderEl.innerText = "⮝"
|
|
||||||
} else {
|
|
||||||
expanderEl.innerText = "⮟"
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.from(expanderEl.parentElement.getElementsByClassName("author-hidden")).forEach(authorEl => {
|
|
||||||
if (expanderEl.dataset.expanded === "true") {
|
|
||||||
authorEl.classList.remove("d-none")
|
|
||||||
} else {
|
|
||||||
authorEl.classList.add("d-none")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})()
|
})()
|
||||||
|
|
|
@ -1,77 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
use actix_http::header;
|
|
||||||
use actix_web::{cookie::Cookie, test};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn protected_route_requires_login(pool: PgPool) {
|
|
||||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
|
||||||
let req = test::TestRequest::get().uri("/items").to_request();
|
|
||||||
let res = test::call_service(&srv, req).await;
|
|
||||||
|
|
||||||
assert!(common::assert_redirect(res.map_into_boxed_body()).starts_with("/login"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn login(pool: PgPool) {
|
|
||||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
|
||||||
|
|
||||||
// This is identical to common::session_cookie,
|
|
||||||
// but copied here explicitly to ensure the right functionality is tested.
|
|
||||||
let req = test::TestRequest::post()
|
|
||||||
.uri("/login")
|
|
||||||
.set_form(common::LoginForm::default())
|
|
||||||
.to_request();
|
|
||||||
|
|
||||||
let res = test::call_service(&srv, req).await;
|
|
||||||
let session = Cookie::parse_encoded(
|
|
||||||
res.headers()
|
|
||||||
.clone()
|
|
||||||
.get(header::SET_COOKIE)
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(common::assert_redirect(res.map_into_boxed_body()).starts_with("/"));
|
|
||||||
|
|
||||||
let req = test::TestRequest::get()
|
|
||||||
.uri("/")
|
|
||||||
.cookie(session.clone())
|
|
||||||
.to_request();
|
|
||||||
|
|
||||||
let res = test::call_service(&srv, req).await;
|
|
||||||
|
|
||||||
assert!(res.status().is_success());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ignore = "actix_session::CookieSessionStore does not support invalidating sessions"]
|
|
||||||
#[sqlx::test]
|
|
||||||
async fn logout(pool: PgPool) {
|
|
||||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
|
||||||
|
|
||||||
let session_cookie = common::session_cookie(&srv).await;
|
|
||||||
|
|
||||||
let req = test::TestRequest::post()
|
|
||||||
.uri("/logout")
|
|
||||||
.cookie(session_cookie.clone())
|
|
||||||
.to_request();
|
|
||||||
|
|
||||||
test::call_service(&srv, req).await;
|
|
||||||
|
|
||||||
let req = test::TestRequest::get()
|
|
||||||
.uri("/items")
|
|
||||||
.cookie(session_cookie.clone())
|
|
||||||
.to_request();
|
|
||||||
|
|
||||||
let res = test::call_service(&srv, req).await;
|
|
||||||
|
|
||||||
assert!(common::assert_redirect(res.map_into_boxed_body()).starts_with("/login"));
|
|
||||||
}
|
|
|
@ -1,69 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
use actix_http::header;
|
|
||||||
use actix_web::{cookie::Cookie, dev::ServiceResponse, test};
|
|
||||||
use clap::Parser;
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use li7y::Config;
|
|
||||||
|
|
||||||
pub const SUPERUSER_PASSWORD: &str = "correct horse battery staple";
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct LoginForm {
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LoginForm {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
password: SUPERUSER_PASSWORD.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn config() -> Config {
|
|
||||||
env::set_var("SUPERUSER_PASSWORD", SUPERUSER_PASSWORD);
|
|
||||||
Config::parse_from(Vec::<std::ffi::OsString>::new().iter())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)] // for some reason rustc detects this as unused
|
|
||||||
pub fn assert_redirect(res: ServiceResponse) -> String {
|
|
||||||
assert!(res.status().is_redirection());
|
|
||||||
|
|
||||||
res.headers()
|
|
||||||
.get(header::LOCATION)
|
|
||||||
.expect("No location header set when expected")
|
|
||||||
.to_str()
|
|
||||||
.expect("Location header is not valid UTF-8")
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn session_cookie<'a>(
|
|
||||||
srv: &impl actix_web::dev::Service<
|
|
||||||
actix_http::Request,
|
|
||||||
Response = ServiceResponse<actix_web::body::EitherBody<actix_web::body::BoxBody>>,
|
|
||||||
Error = actix_web::Error,
|
|
||||||
>,
|
|
||||||
) -> Cookie<'a> {
|
|
||||||
let req = test::TestRequest::post()
|
|
||||||
.uri("/login")
|
|
||||||
.set_form(LoginForm::default())
|
|
||||||
.to_request();
|
|
||||||
|
|
||||||
Cookie::parse_encoded(
|
|
||||||
test::call_service(&srv, req)
|
|
||||||
.await
|
|
||||||
.headers()
|
|
||||||
.get(header::SET_COOKIE)
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap()
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
|
@ -1,71 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
use actix_web::{body::MessageBody, test};
|
|
||||||
use sqlx::{query_as, PgPool};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
#[sqlx::test(fixtures("default"))]
|
|
||||||
async fn list(pool: PgPool) {
|
|
||||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
|
||||||
|
|
||||||
let session_cookie = common::session_cookie(&srv).await;
|
|
||||||
|
|
||||||
let req = test::TestRequest::get()
|
|
||||||
.uri("/items")
|
|
||||||
.cookie(session_cookie.clone())
|
|
||||||
.to_request();
|
|
||||||
|
|
||||||
let res = test::call_service(&srv, req).await;
|
|
||||||
|
|
||||||
assert!(res.status().is_success());
|
|
||||||
|
|
||||||
let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap();
|
|
||||||
|
|
||||||
let items: Vec<(Uuid, Option<String>)> = query_as("SELECT id, name FROM items")
|
|
||||||
.fetch_all(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
for (id, name) in items {
|
|
||||||
assert!(body.contains(&format!(r#"href="/item/{id}""#)));
|
|
||||||
|
|
||||||
if let Some(name) = name {
|
|
||||||
assert!(body.contains(&format!(">{name}</a>")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test(fixtures("default"))]
|
|
||||||
async fn show(pool: PgPool) {
|
|
||||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
|
||||||
|
|
||||||
let session_cookie = common::session_cookie(&srv).await;
|
|
||||||
|
|
||||||
let req = test::TestRequest::get()
|
|
||||||
.uri("/item/663f45e6-b11a-4197-8ce4-c784ac9ee617")
|
|
||||||
.cookie(session_cookie.clone())
|
|
||||||
.to_request();
|
|
||||||
|
|
||||||
let res = test::call_service(&srv, req).await;
|
|
||||||
|
|
||||||
assert!(res.status().is_success());
|
|
||||||
|
|
||||||
let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap();
|
|
||||||
|
|
||||||
assert!(body.contains("<h2>Item 2 <"));
|
|
||||||
assert!(body.contains("<th>UUID</th><td>663f45e6-b11a-4197-8ce4-c784ac9ee617</td>"));
|
|
||||||
assert!(body.contains("<th>Name</th><td>Item 2</td>"));
|
|
||||||
assert!(body
|
|
||||||
.contains(r#"href="/item-class/8a979306-b4c6-4ef8-900d-68f64abb2975">Subclass 1.1</a>"#));
|
|
||||||
assert!(body.contains(r#"href="/item/4fc0f5f4-4dca-4c24-844d-1f464cb32afa">Item 1</a>"#));
|
|
||||||
assert!(body.contains(r#"<li class="breadcrumb-item active">Item 2</li>"#));
|
|
||||||
assert!(body.contains(
|
|
||||||
r#"href="/item/049298e2-73db-42fb-957d-a741655648b1">Original Packaging of Item 2</a>"#
|
|
||||||
));
|
|
||||||
assert!(body.contains(">Lorem ipsum 3</td>"));
|
|
||||||
assert!(body.contains(">acquire</"));
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
use actix_web::{body::MessageBody, test};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
#[sqlx::test(fixtures("default"))]
|
|
||||||
async fn is_linked(pool: PgPool) {
|
|
||||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
|
||||||
|
|
||||||
let req = test::TestRequest::get().uri("/login").to_request();
|
|
||||||
|
|
||||||
let res = test::call_service(&srv, req).await;
|
|
||||||
|
|
||||||
assert!(res.status().is_success());
|
|
||||||
|
|
||||||
let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap();
|
|
||||||
|
|
||||||
assert!(body.contains(r#"<a href="/licensing">"#));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test(fixtures("default"))]
|
|
||||||
async fn contains_basic_information(pool: PgPool) {
|
|
||||||
let srv = test::init_service(li7y::app(&common::config(), &pool)).await;
|
|
||||||
|
|
||||||
let req = test::TestRequest::get().uri("/licensing").to_request();
|
|
||||||
|
|
||||||
let res = test::call_service(&srv, req).await;
|
|
||||||
|
|
||||||
assert!(res.status().is_success());
|
|
||||||
|
|
||||||
let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap();
|
|
||||||
|
|
||||||
// crate names
|
|
||||||
assert!(body.contains(">actix-web<"));
|
|
||||||
assert!(body.contains(">sqlx-postgres<"));
|
|
||||||
|
|
||||||
// author names (only me and entries without email address)
|
|
||||||
assert!(body.contains(">Simon Bruder <simon@sbruder.de><"));
|
|
||||||
assert!(body.contains(">RustCrypto Developers<"));
|
|
||||||
|
|
||||||
// license links (only partial, as I don’t want to check class names)
|
|
||||||
assert!(body.contains(r##"href="#license-Apache-2.0">Apache-2.0<"##));
|
|
||||||
assert!(body.contains(r##"href="#license-MIT">MIT<"##));
|
|
||||||
}
|
|
Loading…
Reference in a new issue