♻️ refactor + location conditions

This commit is contained in:
JMARyA 2025-04-16 04:05:29 +02:00
parent ce9cdc4256
commit 3076ebe6a0
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
19 changed files with 1228 additions and 520 deletions

1429
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,13 @@
[package]
name = "cdb"
version = "0.1.0"
edition = "2021"
edition = "2024"
[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,6 +1,8 @@
# 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,3 +75,15 @@ 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,7 +50,20 @@ Example: Freezer
{
"name": "Freezer",
"conditions": {
"temperature": -10
"temperature": -10.0
}
}
```
#### 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,6 +6,8 @@ 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,9 +2,10 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use crate::routes::item::{item_does_not_exist_error, variant_does_not_exist_error, SupplyForm};
use crate::routes::{ApiError, ToAPI};
use crate::routes::ApiError;
use crate::routes::item::{SupplyForm, item_does_not_exist_error, variant_does_not_exist_error};
use crate::{get_itemdb, get_pg};
use based::request::api::ToAPI;
use sqlx::FromRow;
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -1,7 +1,8 @@
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{get_locations, routes::ToAPI};
use crate::{config::get_config, get_locations};
use based::request::api::ToAPI;
/// A Storage Location
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -17,10 +18,31 @@ pub struct Location {
pub conditions: Option<StorageConditions>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StorageConditions {
/// Median temperature
pub temperature: i64,
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()
}
}
impl Location {

View file

@ -167,6 +167,7 @@ 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::{api_error, vec_to_api, FallibleApiResponse, ToAPI, Token},
routes::{FallibleApiResponse, Token, api_error},
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::{item::SupplyForm, ApiError};
use super::{ApiError, item::SupplyForm};
#[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::{ToAPI, Token};
use crate::routes::Token;
use crate::variant::Variant;
use crate::{check_auth, get_itemdb};
use crate::{
db::ItemDB,
routes::{api_error, FallibleApiResponse},
routes::{FallibleApiResponse, api_error},
};
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,
}
/// Consumes a Transaction with Price and Destination
#[post("/demand", data = "<f>")]
/// Consumes a Transaction with Price and Destination
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}))
}
/// Returns all consumed transactions for Item Variant
#[get("/item/<item_id>/<variant_id>/demand?<destination>")]
/// Returns all consumed transactions for Item Variant
pub async fn demand_log_route(
item_id: &str,
variant_id: &str,

View file

@ -1,4 +1,4 @@
use crate::routes::{api_error, ApiError};
use crate::routes::{ApiError, api_error};
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::{api_error, vec_to_api, FallibleApiResponse, ToAPI, Token},
routes::{FallibleApiResponse, Token, api_error},
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,6 +27,42 @@ 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,6 +6,7 @@ mod supply;
use std::str::FromStr;
use based::request::api::ToAPI;
pub use demand::*;
pub use error::*;
pub use location::*;
@ -17,25 +18,24 @@ use serde::Serialize;
pub use stat::*;
pub use supply::*;
use rocket::get;
use rocket::State;
use rocket::get;
use serde_json::json;
use crate::check_auth;
use crate::config::Config;
use crate::db::get_items_without_min_satisfied;
use crate::db::ItemDB;
use crate::db::get_items_without_min_satisfied;
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;
/// Returns a JSON response with all items in the database.
#[get("/items")]
/// Returns a JSON response with all items in the database.
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}))
}
/// Return an API Response for an `Item`
#[get("/item/<item_id>")]
/// Return an API Response for an `Item`
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
}
/// Returns all variants of an Item
#[get("/item/<item_id>/variants")]
/// Returns all variants of an Item
pub fn item_variants_page(
item_id: &str,
itemdb: &State<ItemDB>,
@ -112,8 +112,8 @@ pub async fn transaction_route(
Ok(t.api().await)
}
/// Returns unique values for a field
#[get("/item/<item_id>/<variant_id>/unique?<field>")]
/// Returns unique values for a field
pub async fn unique_field_route(
item_id: &str,
variant_id: &str,
@ -131,23 +131,28 @@ pub async fn unique_field_route(
.ok_or_else(variant_does_not_exist_error)?;
match field {
"origin" => Ok(json!(variant
"origin" => Ok(json!(
variant
.get_unique_origins()
.await
.into_iter()
.filter(|x| !x.starts_with("flow::"))
.collect::<Vec<_>>())),
"destination" => Ok(json!(variant
.collect::<Vec<_>>()
)),
"destination" => Ok(json!(
variant
.get_unique_destinations()
.await
.into_iter()
.filter(|x| !x.starts_with("flow::"))
.collect::<Vec<_>>())),
.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,
@ -169,6 +174,7 @@ 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,
@ -197,6 +203,7 @@ 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::{get, State};
use rocket::{State, get};
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::{api_error, FallibleApiResponse},
routes::{FallibleApiResponse, api_error},
};
use super::{item_does_not_exist_error, variant_does_not_exist_error};
@ -48,11 +48,13 @@ pub async fn variant_price_latest_by_origin(
.variant(variant_id)
.ok_or_else(variant_does_not_exist_error)?;
Ok(json!(variant
Ok(json!(
variant
.price_history_by_origin(origin, Some(1))
.await
.first()
.unwrap()))
.unwrap()
))
}
#[get("/items/stat")]
@ -73,7 +75,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;
total_price += t.price.unwrap_or_default();
}
}
}

View file

@ -1,15 +1,15 @@
use rocket::serde::json::Json;
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::routes::Token;
use crate::{
db::ItemDB,
routes::{api_error, FallibleApiResponse},
routes::{FallibleApiResponse, api_error},
};
use based::request::api::{ToAPI, vec_to_api};
use rocket::serde::json::Json;
use rocket::{State, get, post};
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::{item_does_not_exist_error, variant_does_not_exist_error};
@ -23,8 +23,8 @@ pub struct SupplyForm {
pub note: Option<String>,
}
/// Route for supply action. Creates a new Transaction for the specified Item Variant.
#[post("/supply", data = "<form>")]
/// Route for supply action. Creates a new Transaction for the specified Item Variant.
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}))
}
/// Returns a list of Transaction UUIDs for the Item Variant
#[get("/item/<item_id>/<variant_id>/supply")]
/// Returns a list of Transaction UUIDs for the Item Variant
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))
}
/// Returns current active Transactions for Item
#[get("/item/<item_id>/inventory?<origin>")]
/// Returns current active Transactions for Item
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))
}
/// Returns current active Transactions for Item Variant
#[get("/item/<item_id>/<variant_id>/inventory")]
/// Returns current active Transactions for Item Variant
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))
}
/// Returns statistics for the Item Variant
#[get("/item/<item_id>/<variant_id>/stat?<full>")]
/// Returns statistics for the Item Variant
pub async fn variant_stat_route(
item_id: &str,
variant_id: &str,

View file

@ -1,5 +1,5 @@
use rocket::{
http::Status, outcome::Outcome, request::FromRequest, response::status::BadRequest, Request,
Request, http::Status, outcome::Outcome, request::FromRequest, response::status::BadRequest,
};
use serde_json::json;
@ -37,20 +37,3 @@ 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,9 +1,10 @@
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, routes::ToAPI};
use crate::{get_itemdb, get_locations, get_pg, item::Item, variant::Variant};
// todo : produced / consumed by flow field?
@ -17,7 +18,7 @@ pub struct Transaction {
/// Associated Variant
pub variant: String,
/// Price of obtaining the Item
pub price: f64,
pub price: Option<f64>,
/// Origin of the Item
pub origin: Option<String>,
/// The location of the Item
@ -53,6 +54,14 @@ 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)
@ -157,11 +166,7 @@ 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, transaction::Transaction};
use crate::{get_pg, location::StorageConditions, 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, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Variant {
/// Associated Item
pub item: String,
@ -45,6 +45,14 @@ 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 {
@ -78,6 +86,21 @@ 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(),
],
}
}),
}
}
@ -234,10 +257,29 @@ 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).sum();
let total_price: f64 = active_transactions
.iter()
.map(|x| x.price.unwrap_or_default())
.sum();
if !full {
return json!({