Compare commits

..

No commits in common. "6ed282ea1c60688103f622a3694e396cd2c406fd" and "d46a1029ad966699d31b54de3c785e013c5c9ed6" have entirely different histories.

19 changed files with 514 additions and 1222 deletions

1413
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,12 @@
[package]
name = "cdb"
version = "0.1.0"
edition = "2024"
edition = "2021"
[dependencies]
futures = "0.3.30"
log = "0.4.20"
mdq = { git = "https://git.hydrar.de/mdtools/mdq" }
based = { git = "https://git.hydrar.de/jmarya/based" }
rocket = { version = "0.5.1", features = ["json"] }
rocket_cors = "0.6.0"
serde = { version = "1.0.195", features = ["derive"] }

View file

@ -1,8 +1,6 @@
# Only these tokens are authorized to make requests
allowed_tokens = ["password"]
home_assistant = "TOKEN"
# Webhook Notifications
[webhook]
# Item Variants inventory goes below the minimum

View file

@ -75,15 +75,3 @@ variants:
name: "Regular Water"
barcodes: [12345678]
```
### Need Conditions
Some items prefer or need to be stored under some conditions. These conditions can be configured and warn you when locations dont match the required conditions.
```yml
name: "Water"
variants:
regular:
name: "Regular Water"
needs:
temperature: [5.0, 10.0]
```

View file

@ -50,20 +50,7 @@ Example: Freezer
{
"name": "Freezer",
"conditions": {
"temperature": -10.0
"temperature": -10
}
}
```
#### Fetch temperature from Home Assistant
You can fetch the temperature from a sensor via Home Assistant.
You need to setup a API Token for Home Assistant. After that you can enter the sensors API route in the location definition:
```json
{
"name": "Freezer",
"conditions": {
"temperature_sensor": "https://homeassistant.local/api/states/sensor.freezer_temperature"
}
}
```

View file

@ -6,8 +6,6 @@ pub struct Config {
pub allowed_tokens: Vec<String>,
/// Webhook Config
pub webhook: Option<Webhook>,
/// Home Assistant Token
pub home_assistant: Option<String>,
}
pub fn get_config() -> Config {

View file

@ -2,10 +2,9 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use crate::routes::ApiError;
use crate::routes::item::{SupplyForm, item_does_not_exist_error, variant_does_not_exist_error};
use crate::routes::item::{item_does_not_exist_error, variant_does_not_exist_error, SupplyForm};
use crate::routes::{ApiError, ToAPI};
use crate::{get_itemdb, get_pg};
use based::request::api::ToAPI;
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -1,8 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{config::get_config, get_locations};
use based::request::api::ToAPI;
use crate::{get_locations, routes::ToAPI};
/// A Storage Location
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -18,31 +17,10 @@ pub struct Location {
pub conditions: Option<StorageConditions>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StorageConditions {
/// Median temperature
pub temperature: f64,
/// Accurate temperature sensor reading
pub temperature_sensor: Option<String>,
}
impl StorageConditions {
/// Get a accurate temperature from a sensor endpoint
pub async fn accurate_temperature(&self) -> Option<f64> {
let conf = get_config();
let client = reqwest::Client::new();
let res: serde_json::Value = client
.get(self.temperature_sensor.clone()?)
.bearer_auth(conf.home_assistant?)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
res.as_object()?.get("state")?.as_f64()
}
pub temperature: i64,
}
impl Location {

View file

@ -167,7 +167,6 @@ async fn rocket() -> _ {
routes::flow::end_flow_route,
routes::flow::continue_flow_route,
routes::flow::create_flow_route,
routes::item::location_condition_warn,
routes::item::move_transaction_route,
routes::item::variant_price_latest_by_origin,
routes::item::item_stat_route,

View file

@ -1,19 +1,19 @@
use rocket::{get, post, serde::json::Json, State};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{collections::HashMap, str::FromStr};
use crate::{
check_auth,
config::Config,
flow::{Flow, FlowInfo, FlowNote},
get_pg,
json_store::JSONStore,
routes::{FallibleApiResponse, Token, api_error},
routes::{api_error, vec_to_api, FallibleApiResponse, ToAPI, Token},
transaction::Transaction,
};
use based::request::api::{ToAPI, vec_to_api};
use rocket::{State, get, post, serde::json::Json};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{collections::HashMap, str::FromStr};
use super::{ApiError, item::SupplyForm};
use super::{item::SupplyForm, ApiError};
#[get("/flow/<id>/info")]
pub async fn flow_info(

View file

@ -1,18 +1,18 @@
use std::str::FromStr;
use rocket::serde::json::Json;
use rocket::{get, post, State};
use serde::Deserialize;
use serde_json::json;
use crate::config::{Config, Webhook};
use crate::routes::Token;
use crate::routes::{ToAPI, Token};
use crate::variant::Variant;
use crate::{check_auth, get_itemdb};
use crate::{
db::ItemDB,
routes::{FallibleApiResponse, api_error},
routes::{api_error, FallibleApiResponse},
};
use based::request::api::ToAPI;
use rocket::serde::json::Json;
use rocket::{State, get, post};
use serde::Deserialize;
use serde_json::json;
use super::{item_does_not_exist_error, variant_does_not_exist_error};
@ -23,8 +23,8 @@ pub struct DemandForm {
price: f64,
}
#[post("/demand", data = "<f>")]
/// Consumes a Transaction with Price and Destination
#[post("/demand", data = "<f>")]
pub async fn demand_route(f: Json<DemandForm>, t: Token, c: &State<Config>) -> FallibleApiResponse {
check_auth!(t, c);
@ -66,8 +66,8 @@ pub async fn demand_route(f: Json<DemandForm>, t: Token, c: &State<Config>) -> F
Ok(json!({"ok": 1}))
}
#[get("/item/<item_id>/<variant_id>/demand?<destination>")]
/// Returns all consumed transactions for Item Variant
#[get("/item/<item_id>/<variant_id>/demand?<destination>")]
pub async fn demand_log_route(
item_id: &str,
variant_id: &str,

View file

@ -1,4 +1,4 @@
use crate::routes::{ApiError, api_error};
use crate::routes::{api_error, ApiError};
pub fn item_does_not_exist_error() -> ApiError {
api_error("The item does not exist")

View file

@ -1,16 +1,16 @@
use std::collections::HashMap;
use rocket::{get, State};
use serde_json::json;
use crate::{
check_auth,
config::Config,
json_store::JSONStore,
location::Location,
routes::{FallibleApiResponse, Token, api_error},
routes::{api_error, vec_to_api, FallibleApiResponse, ToAPI, Token},
transaction::Transaction,
};
use based::request::api::{ToAPI, vec_to_api};
use rocket::{State, get};
use serde_json::json;
#[get("/location/<id>")]
pub async fn location_info(
@ -27,42 +27,6 @@ pub async fn location_info(
Ok(loc.api().await)
}
#[get("/location/<id>/warn")]
/// API route returning affected items on a condition mismatch
pub async fn location_condition_warn(
id: &str,
locations: &State<JSONStore<Location>>,
t: Token,
c: &State<Config>,
) -> FallibleApiResponse {
check_auth!(t, c);
let loc = locations
.get(id)
.ok_or_else(|| api_error("No location with that ID"))?;
let transactions = Transaction::in_location(&loc.id).await;
let mut ret = Vec::new();
for t in transactions {
if let Some(item) = t.item_variant().await {
if !item
.satisfy_condition(
loc.conditions
.as_ref()
.ok_or(api_error("Location has no conditions"))?,
)
.await
{
ret.push(t);
}
}
}
Ok(json!(vec_to_api(&ret).await))
}
/// Resolve locations children recursively
async fn location_api_resolve(
id: &str,

View file

@ -6,7 +6,6 @@ mod supply;
use std::str::FromStr;
use based::request::api::ToAPI;
pub use demand::*;
pub use error::*;
pub use location::*;
@ -18,24 +17,25 @@ use serde::Serialize;
pub use stat::*;
pub use supply::*;
use rocket::State;
use rocket::get;
use rocket::State;
use serde_json::json;
use crate::check_auth;
use crate::config::Config;
use crate::db::ItemDB;
use crate::db::get_items_without_min_satisfied;
use crate::db::ItemDB;
use crate::get_locations;
use crate::get_pg;
use crate::routes::ToAPI;
use crate::routes::Token;
use crate::transaction::Transaction;
use super::api_error;
use crate::routes::FallibleApiResponse;
#[get("/items")]
/// Returns a JSON response with all items in the database.
#[get("/items")]
pub fn get_items_route(itemdb: &State<ItemDB>, t: Token, c: &State<Config>) -> FallibleApiResponse {
check_auth!(t, c);
@ -47,8 +47,8 @@ pub fn get_items_route(itemdb: &State<ItemDB>, t: Token, c: &State<Config>) -> F
Ok(json!({"items": items}))
}
#[get("/item/<item_id>")]
/// Return an API Response for an `Item`
#[get("/item/<item_id>")]
pub fn item_route(
item_id: &str,
itemdb: &State<ItemDB>,
@ -74,8 +74,8 @@ pub async fn item_image_route(item_id: &str, itemdb: &State<ItemDB>) -> Option<N
None
}
#[get("/item/<item_id>/variants")]
/// Returns all variants of an Item
#[get("/item/<item_id>/variants")]
pub fn item_variants_page(
item_id: &str,
itemdb: &State<ItemDB>,
@ -112,8 +112,8 @@ pub async fn transaction_route(
Ok(t.api().await)
}
#[get("/item/<item_id>/<variant_id>/unique?<field>")]
/// Returns unique values for a field
#[get("/item/<item_id>/<variant_id>/unique?<field>")]
pub async fn unique_field_route(
item_id: &str,
variant_id: &str,
@ -131,28 +131,23 @@ pub async fn unique_field_route(
.ok_or_else(variant_does_not_exist_error)?;
match field {
"origin" => Ok(json!(
variant
.get_unique_origins()
.await
.into_iter()
.filter(|x| !x.starts_with("flow::"))
.collect::<Vec<_>>()
)),
"destination" => Ok(json!(
variant
.get_unique_destinations()
.await
.into_iter()
.filter(|x| !x.starts_with("flow::"))
.collect::<Vec<_>>()
)),
"origin" => Ok(json!(variant
.get_unique_origins()
.await
.into_iter()
.filter(|x| !x.starts_with("flow::"))
.collect::<Vec<_>>())),
"destination" => Ok(json!(variant
.get_unique_destinations()
.await
.into_iter()
.filter(|x| !x.starts_with("flow::"))
.collect::<Vec<_>>())),
_ => Err(api_error("Unknown field")),
}
}
#[get("/items/expired?<days>")]
/// Get all expired transactions
pub async fn expired_items_route(
days: Option<&str>,
t: Token,
@ -174,7 +169,6 @@ pub async fn expired_items_route(
}
#[get("/items/min")]
/// Return all items without minimum satisfied
pub async fn min_items_route(
itemdb: &State<ItemDB>,
t: Token,
@ -203,7 +197,6 @@ pub struct MoveTransaction {
}
#[post("/transaction/<id>/move", data = "<form>")]
/// Move a transaction to a new location
pub async fn move_transaction_route(id: &str, form: Json<MoveTransaction>) -> FallibleApiResponse {
let new_loc = &form.to;
let locations = get_locations!();

View file

@ -1,4 +1,4 @@
use rocket::{State, get};
use rocket::{get, State};
use serde_json::json;
use crate::check_auth;
@ -6,7 +6,7 @@ use crate::config::Config;
use crate::routes::Token;
use crate::{
db::ItemDB,
routes::{FallibleApiResponse, api_error},
routes::{api_error, FallibleApiResponse},
};
use super::{item_does_not_exist_error, variant_does_not_exist_error};
@ -48,13 +48,11 @@ pub async fn variant_price_latest_by_origin(
.variant(variant_id)
.ok_or_else(variant_does_not_exist_error)?;
Ok(json!(
variant
.price_history_by_origin(origin, Some(1))
.await
.first()
.unwrap()
))
Ok(json!(variant
.price_history_by_origin(origin, Some(1))
.await
.first()
.unwrap()))
}
#[get("/items/stat")]
@ -75,7 +73,7 @@ pub async fn item_stat_route(
let item_var = itemdb.get_item(&item).unwrap().variant(var).unwrap();
for t in item_var.inventory().await {
transaction_count += 1;
total_price += t.price.unwrap_or_default();
total_price += t.price;
}
}
}

View file

@ -1,16 +1,16 @@
use crate::check_auth;
use crate::config::{Config, Webhook};
use crate::routes::Token;
use crate::{
db::ItemDB,
routes::{FallibleApiResponse, api_error},
};
use based::request::api::{ToAPI, vec_to_api};
use rocket::serde::json::Json;
use rocket::{State, get, post};
use rocket::{get, post, State};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::check_auth;
use crate::config::{Config, Webhook};
use crate::routes::{vec_to_api, ToAPI, Token};
use crate::{
db::ItemDB,
routes::{api_error, FallibleApiResponse},
};
use super::{item_does_not_exist_error, variant_does_not_exist_error};
#[derive(Deserialize, Debug, Clone, Serialize)]
@ -23,8 +23,8 @@ pub struct SupplyForm {
pub note: Option<String>,
}
#[post("/supply", data = "<form>")]
/// Route for supply action. Creates a new Transaction for the specified Item Variant.
#[post("/supply", data = "<form>")]
pub async fn supply_route(
form: Json<SupplyForm>,
itemdb: &State<ItemDB>,
@ -57,8 +57,8 @@ pub async fn supply_route(
Ok(json!({"uuid": transaction.id}))
}
#[get("/item/<item_id>/<variant_id>/supply")]
/// Returns a list of Transaction UUIDs for the Item Variant
#[get("/item/<item_id>/<variant_id>/supply")]
pub async fn supply_log_route(
item_id: &str,
variant_id: &str,
@ -79,8 +79,8 @@ pub async fn supply_log_route(
Ok(json!(transactions))
}
#[get("/item/<item_id>/inventory?<origin>")]
/// Returns current active Transactions for Item
#[get("/item/<item_id>/inventory?<origin>")]
pub async fn inventory_route(
item_id: &str,
itemdb: &State<ItemDB>,
@ -103,8 +103,8 @@ pub async fn inventory_route(
Ok(json!(vec_to_api(&transactions).await))
}
#[get("/item/<item_id>/<variant_id>/inventory")]
/// Returns current active Transactions for Item Variant
#[get("/item/<item_id>/<variant_id>/inventory")]
pub async fn inventory_route_variant(
item_id: &str,
variant_id: &str,
@ -125,8 +125,8 @@ pub async fn inventory_route_variant(
Ok(json!(vec_to_api(&transactions).await))
}
#[get("/item/<item_id>/<variant_id>/stat?<full>")]
/// Returns statistics for the Item Variant
#[get("/item/<item_id>/<variant_id>/stat?<full>")]
pub async fn variant_stat_route(
item_id: &str,
variant_id: &str,

View file

@ -1,5 +1,5 @@
use rocket::{
Request, http::Status, outcome::Outcome, request::FromRequest, response::status::BadRequest,
http::Status, outcome::Outcome, request::FromRequest, response::status::BadRequest, Request,
};
use serde_json::json;
@ -37,3 +37,20 @@ impl<'r> FromRequest<'r> for Token {
}
}
}
/// A trait to generate a Model API representation in JSON format.
pub trait ToAPI: Sized {
/// Generate public API JSON
fn api(&self) -> impl std::future::Future<Output = serde_json::Value>;
}
/// Converts a slice of items implementing the `ToAPI` trait into a `Vec` of JSON values.
pub async fn vec_to_api(items: &[impl ToAPI]) -> Vec<serde_json::Value> {
let mut ret = Vec::with_capacity(items.len());
for e in items {
ret.push(e.api().await);
}
ret
}

View file

@ -1,10 +1,9 @@
use based::request::api::ToAPI;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::prelude::FromRow;
use crate::{get_itemdb, get_locations, get_pg, item::Item, variant::Variant};
use crate::{get_itemdb, get_locations, get_pg, routes::ToAPI};
// todo : produced / consumed by flow field?
@ -18,7 +17,7 @@ pub struct Transaction {
/// Associated Variant
pub variant: String,
/// Price of obtaining the Item
pub price: Option<f64>,
pub price: f64,
/// Origin of the Item
pub origin: Option<String>,
/// The location of the Item
@ -54,14 +53,6 @@ impl Transaction {
.fetch_one(get_pg!()).await.unwrap()
}
pub async fn item(&self) -> Option<&Item> {
get_itemdb!().get_item(&self.item)
}
pub async fn item_variant(&self) -> Option<Variant> {
get_itemdb!().get_item(&self.item)?.variant(&self.variant)
}
pub async fn get(id: &uuid::Uuid) -> Option<Self> {
sqlx::query_as("SELECT * FROM transactions WHERE id = $1")
.bind(id)
@ -166,7 +157,11 @@ impl Transaction {
item.is_expired().await
};
if expired { Some(item) } else { None }
if expired {
Some(item)
} else {
None
}
})
.collect()
.await;

View file

@ -3,7 +3,7 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{get_pg, location::StorageConditions, transaction::Transaction};
use crate::{get_pg, transaction::Transaction};
pub fn timestamp_range(year: i32, month: u32) -> (i64, i64) {
let d = chrono::NaiveDate::from_ymd_opt(year, month, 0).unwrap();
@ -31,7 +31,7 @@ pub fn timestamp_range(year: i32, month: u32) -> (i64, i64) {
/// in the real world. It may include attributes or properties that deviate from
/// the standard definition of the item. For example, different colors, sizes, or
/// configurations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Variant {
/// Associated Item
pub item: String,
@ -45,14 +45,6 @@ pub struct Variant {
pub expiry: Option<i64>,
/// Associated barcodes
pub barcodes: Option<Vec<i64>>,
/// Variant Need Conditions
pub needs: Option<VariantNeedCondition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VariantNeedCondition {
/// Required temperature range (min-max)
pub temperature: [f64; 2],
}
impl Variant {
@ -86,21 +78,6 @@ impl Variant {
.map(|x| x.as_i64().unwrap())
.collect()
}),
needs: json.as_mapping().unwrap().get("needs").map(|x| {
let temp_range = x
.as_mapping()
.unwrap()
.get("temperature")
.unwrap()
.as_sequence()
.unwrap();
VariantNeedCondition {
temperature: [
temp_range.get(0).unwrap().as_f64().unwrap(),
temp_range.get(1).unwrap().as_f64().unwrap(),
],
}
}),
}
}
@ -257,29 +234,10 @@ impl Variant {
(false, 0)
}
pub async fn satisfy_condition(&self, conditions: &StorageConditions) -> bool {
if let Some(needs) = &self.needs {
if let Some(room_temp) = conditions.accurate_temperature().await {
if needs.temperature[0] < room_temp && room_temp < needs.temperature[1] {
return true;
} else {
return false;
}
} else {
log::warn!("Could not get temperature for location");
}
}
true
}
pub async fn stat(&self, full: bool) -> serde_json::Value {
let active_transactions = self.inventory().await;
let total_price: f64 = active_transactions
.iter()
.map(|x| x.price.unwrap_or_default())
.sum();
let total_price: f64 = active_transactions.iter().map(|x| x.price).sum();
if !full {
return json!({